Structured results for codemode
docs/usage/structured-results.mdin the repository. Edit that file and this page follows — there is no second copy of it, and the text below is not maintained here. Source: Structured results for codemode.Every model-facing tool this package registers returns two result channels:
| channel | who reads it | what it is |
|---|---|---|
content | the model, and the TUI | the text block(s) — unchanged by anything below |
details | the TUI renderers | this package's own richer structure, per tool |
structuredContent | codemode scripts only | a lean projection, declared by outputSchema |
structuredContent is the one that is easy to get wrong by assumption, so this page states what it is for and what it is not.
Why it exists
pi 1.0.0 ships a built-in codemode extension: the model writes a JavaScript program, the program calls other tools, and only the program's own output reaches the model. That makes several things possible that a flat tool call cannot do — fan out to N subagents in one call, filter and aggregate their output before it reaches the transcript, keep a value in store() between calls.
It also breaks a tool whose result is prose. ptc_subagent is the acute case: it is the only way a session that has handed orchestration to codemode can start a subagent at all, because the QuickJS sandbox has no file system, no network and no child_process. A script calling it used to get back the string "Started background task 01JABC…" and had to regex the id out.
What a script gets
// pi 0.99.1+ / 1.0.0
const started = await tools.ptc_subagent({ task: "survey the auth module", background: true });
text(`spawned ${started.task_id}`); // an object, not a string
const rows = await tools.ptc_task_list({ status: ["running"] });
for (const t of rows.tasks) {
if (t.output_bytes > 100_000) text(`${t.id} is large`);
}| tool | structuredContent |
|---|---|
ptc_task_list | { tasks: [{ id, status, agent, depth, label, output_bytes?, error_message? }], count } |
ptc_task_output | { task_id, status, output, output_bytes, output_preview?, output_truncated, output_full_path?, report_channel?, read_only_tier?, report?, usage? } |
ptc_task_stop | { task_id, status, from_status, stop_reason? } |
ptc_subagent | { task_id?, status, exit_code?, report_channel, read_only_tier?, report?, usage? } |
Optional keys are omitted, never present-and-null, so Object.hasOwn(t, "error_message") is the test for "this task had an error", not t.error_message !== undefined.
Two fields carry the useful asymmetry:
ptc_subagent:task_idis present only for a background spawn andexit_codeonly for a foreground one, so a script can tell "I have a handle, poll it" from "this already finished" without parsing prose.ptc_task_output:statusis the task's current state, so a polling script does not need a secondptc_task_listcall.
usage (issue #199) is the host's own count — input, output, cost, turns, the same four fields report.usage carries — and it is sourced from the record's / result's own accumulator rather than from the report. That is what makes it readable for a child with no report: a report_channel of none or opted-out, and a failed or canceled ptc_task_output, still account for what the child cost. Absent means no top-level count was recorded: a running task, a lost record, a record written before the field existed (its report, when one exists, still renders the report's own host-stamped numbers), or a background handle in ptc_subagent. The text block states the same four numbers on its usage line, so the two channels never disagree about a measurement.
What it is not
- Not sent to the model.
contentremains the model-facing result;structuredContentis documented as "for programmatic callers" and is stripped when the tool-result message is built. Declaring one costs zero prompt tokens and changes nothing about what a model sees. - Not validated. pi never checks that
structuredContentmatches the declaredoutputSchema. Nothing will tell you if they drift; that is why the shapes above are pinned by tests. - Not a mirror of
details.detailscarries what the TUI needs, including a per-recordoutputPreviewof up to 2 KB; a 100-rowptc_task_listmirroring it would push ~200 KB into a sandbox whose purpose is to keep intermediate data away from the model. The two channels are allowed to drift, deliberately. - Not a failure channel. The
ptc_task_*andptc_subagenterror paths throw. A script gets a rejected promise, not a structured error object — pi has no channel for that on this API.
pi.dispatch declares no structuredContent and is unchanged: it resolves to { text, status, … } with no content at all, and the binding contract already names it as the exception.
Which surface am I on?
structuredContent matters when orchestration went to codemode — the subagents surface. On the full surface the model drives ptc_run_code / ptc_workflow instead, where bindings are the program's own calls and the binding contract is what governs their return shape ({ content, details }, failures REJECT). See ADR-0025 for how the surface is chosen and ADR-0027 for the default.
Turning codemode on
codemode registers inactive on a default pi session, so the surface above is only reachable once you add it to your tool list:
{ "defaultTools": ["read", "bash", "edit", "write", "+codemode"] }Without it, a subagents-surface session has ptc_subagent and no orchestrator to call it from.
That list is pi's own tool set, which is not the same question as what a PTC program may bind: bash being in defaultTools enables it for pi, but binding tools.bash to a program is a separate opt-in — see Trust posture.