Return structured tool results and route nested calls
Give a tool an output schema and annotations, and run other tools with ctx.executeTool so the same tool_call gate applies to nested calls.
A tool that executes returns data as well as text: a machine-readable structuredContent for
scripts alongside the content the model reads. This page’s tools declare risk with annotations and
run one another through ctx.executeTool(), and those nested calls pass through the same tool_call
gate as model-issued calls. The behaviour below is observed through a real session; the model is
a faux provider replaying a script.
Run it
A non-zero exit is a result, not a failed tool: runCommand in tools.ts catches exec’s
rejection and returns the exit code as data. When the result is data, declare an outputSchema and
return a matching structuredContent; the caller below reads the value back from
outcome.result. Annotations are hints a permission extension can read — with no hints,
needsApproval is true (approval.ts):
pi.on("tool_call", async (event, ctx) => {
const hints = pi.getAllTools().find((tool) => tool.name === event.toolName)?.annotations;
const needsApproval =
hints?.destructiveHint === true ||
(!hints?.readOnlyHint && ((hints?.destructiveHint ?? true) || (hints?.openWorldHint ?? true)));
if (needsApproval && !(await ctx.ui.confirm("Allow tool call?", event.toolName))) {
return { block: true, reason: `${event.toolName} was not approved` };
}
});Nested calls use ctx, the tool’s fifth argument (tools.ts). executeTool() validates
arguments and passes through tool_call; it never rejects for a tool failure:
async execute(_id, _params, signal, onUpdate, ctx) {
const checks = await ctx.executeTool("run_checks", {}, { signal, onUpdate });
const tests = await ctx.executeTool("failing_tests", {}, { signal, onUpdate });Expected outcome
| Tool or hook | Assertion from tools.test.ts |
|---|---|
run_checks |
a non-zero command exit is isError: false, with the exit code in details |
failing_tests |
text reads 1 file(s) with failing tests; structuredContent carries the file list |
review_diff |
isError: false; content includes src/auth/refresh.ts, from outcome.result.structuredContent |
spy tool_call |
sees review_diff, then run_checks, then failing_tests — nested calls pass the same gate |
| approval | readOnlyHint: true is not asked about; a tool with no hints is blocked with no UI |
| dynamic | a deferred tool is inactive until review_diff calls setActiveTools() |
| renderer | the registerToolRenderer extension loads and registers without error |
Mechanism and limitations
Documented: exposure decides declaration — direct and model-only activate on registration,
while codemode, deferred and hidden wait for setActiveTools(). Missing annotation hints take
the MCP defaults (not read-only, possibly destructive and open-world), so an undeclared tool is
treated as dangerous. A data result pairs outputSchema with structuredContent; return
isError: true when you have data and an error, and throw when you have none. Nested
executeTool() calls pass the tool_call/tool_result handlers and add no transcript entries.
Observed: tools.test.ts asserts every row above through a real AgentSession. The spy row is
load-bearing: the Chapter 16 guard would see run_checks and failing_tests even though the model
asked only for review_diff.
Limitations: annotations are unverified hints — nothing checks a false readOnlyHint — so they
inform a guard rather than constrain a call, and exposure, annotations and rendering are three
separate mechanisms. registerToolRenderer() arrived in 1.0.1 and must return a pi-tui
Component, not a plain object. The faux model shows the mechanism, not a real model’s tool choice.
Understand this example
Copy this prompt into your AI tool. No code runs here.
Apply this example
Copy this prompt into your AI tool. No code runs here.