Example

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.

Full source and test .

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.