Tools in Depth
A tool declares a name, description, schema and execute(); this chapter covers exposure, annotations, structured output, terminate, and nested calls.
Chapter 15’s review-scope tool told the model which command to run. That is a tool shaped like a suggestion. This chapter turns it into a tool shaped like a tool: It can execute work, return structured data, publish risk hints, and control how it is exposed to the model.
Five exposures
exposure controls how the model reaches a tool. “Callable” means callable from other tools through ctx.executeTool() — which is what codemode scripts do.
| Exposure | Declared to the model | Callable from ctx.tools |
|---|---|---|
direct (default) |
while active | while active |
model-only |
while active | never |
codemode |
only when activated explicitly | whenever registered |
deferred |
only when activated explicitly | whenever registered, but not listed by codemode |
hidden |
never | never |
Those five rows sit on top of a shorter pipeline — exists, active, declared, called — and collapsing any two of those steps into one is the most common tool bug:
flowchart TD
A["registerTool"] --> B["the tool exists"]
B --> C{"exposure"}
C -->|direct, model-only| D["activated on registration"]
C -->|codemode, deferred, hidden| E["registered but not active"]
E --> F["pi.setActiveTools name"]
F --> D
D --> G["the active set is what the model is shown"]
G --> H["the model issues a tool call"]
H --> I["tool_call handlers may block or patch it"]
I --> J["execute runs"]
J --> K["content and details, or structuredContent"]
Registering a
directormodel-onlytool activates it; the other exposures are not activated on registration.
The active set is the set of tools declared to the model, so activation and declaration are one step rather than two. And a tool cannot be unregistered: to withdraw one, re-register it with exposure: "hidden".
Wrong: "I registered it, so the model can call it."
Correct: "Registration makes a tool exist. Exposure decides whether it is declared to the model, callable from ctx.executeTool, both, or neither. Activation is what puts it in the declared set."
Choosing an exposure for the guard
The review guard uses several tools with different exposure needs:
run_checks— the model should call it directly.direct.collect_diff— useful to other tools, not worth a declaration line.codemode.dangerous_commands— an optional lookup operation intended for programmatic callers rather than direct model use.deferredkeeps it out of the initially declared set, but does not make it inaccessible to other tools.
import { Type } from "@earendil-works/pi-ai";
import { defineTool, type ExtensionAPI } from "@earendil-works/pi-coding-agent";
export const runChecks = defineTool({
name: "run_checks",
label: "Run Checks",
description:
"Run this repository's own type check and unit tests and return the results. " +
"Use before reviewing a diff or claiming a change is verified.",
annotations: { readOnlyHint: false, openWorldHint: false },
exposure: "direct",
parameters: Type.Object({
skipTests: Type.Optional(Type.Boolean({ description: "Run only the type check.", default: false })),
}),
async execute(_id, params, _signal) {
const steps = params.skipTests ? ["pnpm typecheck"] : ["pnpm typecheck", "pnpm test:unit"];
const results = await Promise.all(steps.map((command) => runCommand(command)));
return {
content: [{ type: "text", text: results.map((r) => r.output.slice(0, 1000)).join("\n") }],
details: { steps, results: results.map((r) => ({ exit_code: r.exit_code })) },
};
},
});
export default function (pi: ExtensionAPI) {
pi.registerTool(runChecks);
}
runCommand is yours to supply. Here is one that returns a non-zero exit as data, which is the same rule chapter 6 gave for the built-in bash tool:
import { exec } from "node:child_process";
import { promisify } from "node:util";
const execAsync = promisify(exec);
export async function runCommand(command: string): Promise<{ output: string; exit_code: number }> {
try {
const { stdout, stderr } = await execAsync(command, { maxBuffer: 1024 * 1024 });
return { output: stdout + stderr, exit_code: 0 };
} catch (err: any) {
// exec rejects on a non-zero exit. That is a result, not a tool failure.
return { output: `${err.stdout ?? ""}${err.stderr ?? ""}`, exit_code: err.code ?? 1 };
}
}
The two lines that carry the chapter are exposure: "direct" and the annotations declaration. But run_checks executes repository scripts, which can write files or run arbitrary project code. Unless those commands have been independently constrained to be read-only, readOnlyHint: true is not a defensible annotation. A permission extension must not treat an author-supplied hint as proof that execution is safe.
Annotations are hints, not facts
readOnlyHint, destructiveHint, idempotentHint, and openWorldHint carry the same meaning as MCP tool annotations. extensions.md is explicit about the defaults: missing hints take the MCP defaults, so a tool is not read-only, and may be destructive and reach an open world. The hints are not verified, but a permission extension can use them to decide which calls to confirm.
Here is the approval extension the documentation shows:
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) return;
if (!ctx.hasUI) {
return { block: true, reason: `${event.toolName} requires approval, but no UI is available` };
}
if (!(await ctx.ui.confirm("Allow tool call?", event.toolName))) {
return { block: true, reason: `${event.toolName} was not approved` };
}
});
Read the default carefully: with no annotations at all, needsApproval is true. The example approval extension treats an unannotated tool as requiring approval. Other permission extensions may apply different rules. Declare readOnlyHint: true only when the operation is genuinely read-only; an annotation is an assertion by the tool author, not a restriction enforced by Pi.
Returning data rather than text
extensions.md draws the line precisely:
Declare
outputSchemaand return a matchingstructuredContentwhen the result is data. The model still receivescontent; programmatic callers such as codemode scripts receivestructuredContentinstead of the text.
So a tool can answer two audiences at once. content gives the model a readable result; structuredContent supplies typed data to programmatic callers. The example below uses fixed data to demonstrate the contract, not live test discovery.
import { Type } from "@earendil-works/pi-ai";
export const failingTests = defineTool({
name: "failing_tests",
label: "Failing Tests",
description: "List the currently failing unit tests, grouped by file. Use when deciding what to fix.",
parameters: Type.Object({}),
outputSchema: Type.Object({
files: Type.Array(
Type.Object({ path: Type.String(), failed: Type.Integer(), names: Type.Array(Type.String()) }),
),
}),
async execute() {
const files = [
{ path: "src/auth/refresh.ts", failed: 2, names: ["refreshes expired token", "retries once"] },
];
return {
content: [{ type: "text", text: `${files.length} file(s) with failing tests.` }],
structuredContent: { files },
details: { count: files.length },
};
},
});
The failing_tests example above returns a hard-coded fixture, not the current failures from a real test runner. It demonstrates the result schema; it does not discover failing tests. A production implementation would have to run and parse the project’s test output.
And the failure case, which is the one people get wrong:
To report a failure that still carries data, return the result with
isError: trueinstead of throwing: the model sees an error, and scripts still receivestructuredContent.
Throw when you have no data. Return isError: true when you have data and an error.
Truncation is a contract
extensions.md instructs: truncate large model-facing results and tell the model where to read the complete output.
bash sets the precedent — its output holds up to 1 MiB, longer output keeps its first and last 512 KiB around an omission marker, and full_output_path holds the rest. Your tool should do the same, and name the file in content so the model can read it if it needs to.
terminate and sequencing
extensions.md: return terminate: true only when the agent should skip its automatic follow-up after every completed tool in that batch agrees to terminate.
“Every completed tool in that batch” is doing real work. One terminate: true among three parallel calls does nothing.
Use sequential execution when tools share mutable in-memory state. File-mutating tools should wrap the complete read-modify-write operation with withFileMutationQueue().
Nested calls
A tool can run other tools with ctx.executeTool(name, args, { signal, onUpdate }). The documented consequences, all of which matter:
- Nested calls go through argument validation and the
tool_callandtool_resulthandlers like model-issued calls. Your guard from chapter 16 sees them. - They emit
tool_execution_start,tool_execution_update, andtool_execution_end, all carryingparentToolCallId, withtoolCallIdassigned by Pi as<parent id>/<n>. - Those ids never appear as tool calls or tool results in the transcript.
- Nested calls add no transcript entries; their results only reach the calling tool.
- The session keeps a bounded record as
nestedCallson the calling tool’s result message — name, arguments, status, duration, error, never results. Arguments over 8 KiB per call or 32 KiB per tool result are omitted, at most 256 calls are kept, andcomplete: falsemarks a record that lost anything. - The
usageof nested results, at every depth, is added to the calling tool’s resultusage.
That last point pairs with message-types.md, which says a tool result’s optional usage reports nested model work and contributes to full-session statistics. Your tool reports only its own usage; its children’s usage is added on top.
export const reviewDiff = defineTool({
name: "review_diff",
label: "Review Diff",
description: "Run the project's checks and list failing tests for the changed files, then summarise both.",
parameters: Type.Object({}),
// ctx is the fifth argument: an ExtensionToolContext, which is the extension
// context plus executeTool().
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 });
// executeTool() never rejects for a tool failure. It resolves to an outcome,
// { toolCall, result, isError }, and the tool's own content and
// structuredContent are on outcome.result.
const checksText = checks.result.content.find((c) => c.type === "text")?.text ?? "none";
return {
content: [
{ type: "text", text: `checks${checks.isError ? " (failed)" : ""}: ${checksText}` },
{ type: "text", text: `tests: ${tests.result.structuredContent ? JSON.stringify(tests.result.structuredContent) : "none"}` },
],
details: { nested: ["run_checks", "failing_tests"] },
};
},
});
Because nested calls pass through tool_call, an orchestrator like this inherits your guard. That is a feature: the guard applies to code paths the model never sees.
Dynamic activation
extensions.md gives the pattern: register every tool first, keep optional tools inactive, and use pi.setActiveTools() from a loader tool to select the desired active tools. Names must already be registered; unknown names are ignored.
pi.on("tool_call", async (event) => {
if (event.toolName !== "review_diff") return;
const current = pi.getActiveTools();
if (!current.includes("failing_tests")) {
pi.setActiveTools([...current, "failing_tests"]);
}
});
Pi records the initial prompt and tool set in the transcript’s first system message, then appends tool and prompt changes before the next model request. Providers that cannot represent the transition receive a complete transcript checkpoint, which can invalidate the cached prefix — a real cost of frequent toggling.
Namespaces
namespace: { name, description, instructions } groups related tools, as MCP servers do. codemode tools list a namespace under one heading with its description. instructions holds longer usage guidance; it is not listed, and codemode scripts read it with describeNamespace(name).
The review guard’s tools are a good namespace candidate. Its description becomes the heading codemode lists, and its instructions can hold the guidance a description has no room for — here, “Call review_diff before reporting on a diff. It runs the project’s own checks; do not substitute your own command.”
Drawing a call: renderers are a separate concern
Everything above decides whether the model may see a tool and whether a guard lets a call through. Neither of those decides how the call looks once it happens, and in Pi those are three genuinely separate mechanisms.
A tool declares its own drawing with two optional properties: renderCall and renderResult. They render the call in the interactive transcript and in HTML exports. This is the tool author’s business, and for most tools there is nothing to do.
pi.registerToolRenderer((toolName, next) => renderers) steps outside that. It chooses renderers for calls to any tool by name — including tools that are not registered in the session yet. The documented case is an MCP tool in a resumed session, before its server has reconnected: there is no registered tool object to ask, and before 1.0.1 the call simply had no drawing.
pi.registerToolRenderer((toolName, next) => {
if (toolName.startsWith("mcp__")) {
return { renderCall: () => new Text("call an MCP server", 0, 0) };
}
return next();
});
The important detail is next(). It asks the remaining resolvers for their renderer, eventually reaching the registered tool’s own renderer if one exists. A resolver can return its own renderer to override that fallback. Returning next() delegates; do not confuse delegation with a guarantee that every renderer is merely a fallback.
Why this belongs in a chapter about tools and not in the UI chapter: rendering is per-tool and travels with the tool, while a global theme or a keybinding is the presentation layer’s. A package that ships a tool ships how that tool looks, without depending on the TUI at all. That is the same separation chapter 31 turns on at a larger scale — a capability should not stop being a capability because the terminal went away.
Three things worth separating, because they are easy to conflate:
| Mechanism | Decides |
|---|---|
exposure |
whether the model is told the tool exists |
annotations |
what a guard infers about the call’s risk |
renderCall / registerToolRenderer |
how the call is drawn once it happens |
None of the three implies either of the others. A tool can be perfectly visible and draw itself in the default shape, or be hidden from the model and still render for a human reading an exported session.
One version note, because this section is version-sensitive in a way the rest of the chapter is not: pi.registerToolRenderer() arrived in 1.0.1. On 1.0.0 the only way to draw a tool’s call was the renderCall and renderResult properties on the tool itself, which requires owning the tool.
The list you should reread
extensions.md closes the tool section with references worth opening: hello.ts, todo.ts, dynamic-tools.ts, and truncated-tool.ts, all under examples/extensions/. dynamic-tools.ts registers tools after session initialisation and adds more at runtime from a command; truncated-tool.ts shows the truncation contract. Start with the one matching your integration point.
What was run
examples/ch17-tools/ compiles and runs every tool and hook in this chapter through a real session. Compiling alone found three defects in earlier drafts of this chapter, and the tests then established the behaviour:
ctxwas not in scope inreview_diff, and its results were read from the wrong object. Both are fixed above.skipTestswas a required boolean although the tool is meant to be callable with no arguments. It is now optional.- The renderer example returned a plain object.
registerToolRendererneeds api-tuiComponent, so{ custom: "…" }did not compile. It now returns aText.
What the tests showed:
- a command that exits non-zero comes back as data:
isErrorisfalseand the exit code is indetails; review_diffruns two other tools throughctx.executeTool()and reads theirstructuredContentfromoutcome.result;- nested calls pass through
tool_call: a spy extension sawreview_diff, thenrun_checks, thenfailing_tests, so a guard applies to code paths the model never sees; - under the approval extension, a tool with
readOnlyHint: trueandopenWorldHint: falseis not asked about, and a tool with no hints is blocked when there is no UI; - a
deferredtool is not active on registration, becomes active when the loader tool callssetActiveTools(), and is callable from another tool in either case.
Verify
/reload
Ask: "list the failing tests"
Expected: if `failing_tests` is registered and active, check whether the model calls it; the displayed implementation returns fixture data, not live failures.
For the force-push guard, use the isolated scripted test in `examples/ch16-events/` rather than asking a live agent to perform a real push. Verify that a matching tool call is blocked before execution.
The isolated guard check demonstrates interception of a matching command. It does not, by itself, verify nested invocation; the separate review_diff test above supplies that evidence.
Exposure and annotations decide whether the model can see a tool and whether a guard lets it through. Neither tells the model the tool is the right answer here — that lives in the prompt, and a description alone does not put it there. Chapter 18 covers the surfaces that do.