Get structured output back
There is no response_format or schema option on tasks or agents. Instead,
give the agent a submit tool whose parameters are your schema, and read
the arguments it was called with. Use this when a program, not a person,
consumes the result.
You need: a recipe you can change (Add a tool) and a runtime you call from code (Run tasks from your backend).
1. Define the submit tool
Pi validates every call against the TypeBox schema and returns errors to the model, which corrects the call.
import { type ExtensionFactory, defineTool } from "@earendil-works/pi-coding-agent";
import { Type } from "typebox";
export const Triage = Type.Object({
category: Type.Union([Type.Literal("billing"), Type.Literal("bug"), Type.Literal("other")]),
priority: Type.Integer({ minimum: 1, maximum: 4 }),
summary: Type.String({ maxLength: 280 }),
});
const submitResult = defineTool({
name: "submit_result",
label: "Submit result",
description: "Submit the final triage. Call exactly once, as your last action.",
parameters: Triage,
execute: async () => ({ content: [{ type: "text" as const, text: "Saved." }], details: {} }),
});
const extension: ExtensionFactory = (pi) => pi.registerTool(submitResult);
export default extension;2. Allow the tool and require it
tools: [read, submit_result]
system_instructions:
mode: append
content: |
Finish every task by calling submit_result exactly once. Do not put the result in prose.3. Read the arguments from the stream
const handle = await runner.tasks.start({ prompt: "Triage ticket 4411: ..." });
const args = new Map<string, string>();
let submitId: string | undefined;
for await (const e of handle.stream()) {
if (e.type === EventType.TOOL_CALL_START && e.toolCallName === "submit_result") submitId = e.toolCallId;
if (e.type === EventType.TOOL_CALL_ARGS) args.set(e.toolCallId, (args.get(e.toolCallId) ?? "") + e.delta);
}
const raw = submitId ? args.get(submitId) : undefined;Later, read it from the conversation instead (query under Verify).
4. Validate, and ask once more if needed
import { Value } from "typebox/value";
const result = raw ? JSON.parse(raw) : undefined;
if (!Value.Check(Triage, result)) {
await runner.tasks.runs.create(taskId, {
kind: "prompt",
prompt: { text: "You did not call submit_result with a valid triage. Call it now." },
});
}Require it, don’t just measure it
A judge measures how often answers cite sources; it can’t
make them. To require a field, put it in the schema, for example
sources: Type.Array(Type.String(), { minItems: 1 }), and show users only the
submit tool’s fields.
Verify
introspection tasks create --runtime support-agent --environment staging \
--prompt "Triage: customer charged twice for one order"
introspection conversations get <task-id> -o json \
--query "items[?attributes.gen_ai.tool.name=='submit_result'].attributes.gen_ai.tool.call.arguments"Expect one JSON string that matches the schema.
Or ask your agent: “Make support-agent return its triage through a submit_result tool.”
Limits
| Problem | Do this |
|---|---|
No submit_result call | Add it to the agent’s tools and require it in the instructions. |
| Several calls | Take the latest, and say “exactly once”. |
| A downstream system rejects the result later | Store the task ID with the record and send a tracked event (Recurring problems and feedback). |