Repository: https://github.com/earendil-works/pi
Accessed on: 2026-05-17
Source merge: consolidates pi_claude.md (Claude direct read), pi_pi.md (Pi maintainer notes, 2026-05-15), and pi_codex.md (Codex agent read, 2026-05-17). Every excerpt re-verified against a fresh clone on 2026-05-17.
Yes — tool use is Pi's core competency. Pi is built around the agent-with-tools model: a single agent loop that emits tool calls, the framework validates and dispatches them, results flow back into the next LLM call. Three layers cooperate:
packages/ai— provider-agnosticTool<TParameters>type using TypeBox schemas. The LLM-facing contract.packages/agent— the runtimeAgentToolinterface extendingToolwithexecute,label,prepareArguments,executionMode, plus the agent loop that drives dispatch with pre/post hooks.packages/coding-agent— seven concrete built-in tools (bash,read,write,edit,grep,find,ls) assembled viacreateAllToolDefinitions, plus the extension system that lets users register custom tools viapi.registerTool().
Strong properties beyond the basics:
- Strict schema validation at the framework level (TypeBox runtime + TypeScript static types). Schemas carry all the way to provider-facing function definitions with no conversion step (TypeBox produces JSON Schema natively).
- Parallel-by-default execution (see
3_parallelization/pi.md) with per-tool sequential override. beforeToolCall/afterToolCallhooks for confirmation, redaction, override, blocking. Extension eventstool_call/tool_resultare wired through these hooks.- Multimodal tool results — tools can return text or image content blocks (
readreturns inline base64 images when the file is an image). - Streaming updates — tools can call
onUpdatemid-execution to surface partial progress viatool_execution_updateevents. prepareArgumentsshim for tolerating malformed model output without losing strict typing.terminate: trueflag on tool results to end the loop without a follow-up LLM turn (used bystructured-output.tsextension).
| Concern | File:line |
|---|---|
LLM-facing Tool type |
packages/ai/src/types.ts:325-330 |
Runtime AgentTool interface (extends Tool) |
packages/agent/src/types.ts:361-385 |
| Tool result type (text + image content + structured details) | packages/agent/src/types.ts:344-354 |
| Pre/post hooks | packages/agent/src/types.ts:262-281 |
| Sequential vs parallel dispatch | packages/agent/src/agent-loop.ts:380-388 |
| Argument preparation + validation + execute + post-hook | packages/agent/src/agent-loop.ts:568-684 |
| Built-in tool inventory (constants + assembly) | packages/coding-agent/src/core/tools/index.ts:83-95, 156-163 |
bash tool (definition + schema) |
packages/coding-agent/src/core/tools/bash.ts:23-32 |
read tool with image content support |
packages/coding-agent/src/core/tools/read.ts:246-275 |
Extension tool_call / tool_result → framework hooks wiring |
packages/coding-agent/src/core/agent-session.ts:378-427 |
Extension API ToolDefinition (parameters: TSchema) |
packages/coding-agent/src/core/extensions/types.ts:426-461 |
| Schema preservation into provider function definitions | packages/ai/src/providers/openai-responses-shared.ts:268-276 |
// packages/ai/src/types.ts:325-330
import type { TSchema } from "typebox";
export interface Tool<TParameters extends TSchema = TSchema> {
name: string;
description: string;
parameters: TParameters;
}Why relevant: The contract every LLM provider in Pi's ai package consumes — Anthropic, OpenAI Responses, OpenAI Completions, Bedrock, Google, Google Vertex, Mistral, Azure OpenAI. The TParameters extends TSchema generic preserves the schema's TypeScript shape end-to-end: Static<TParameters> gives the validated input type at runtime with zero any leaks.
// packages/agent/src/types.ts:361-385
export interface AgentTool<TParameters extends TSchema = TSchema, TDetails = any> extends Tool<TParameters> {
/** Human-readable label for UI display. */
label: string;
/**
* Optional compatibility shim for raw tool-call arguments before schema validation.
* Must return an object that matches `TParameters`.
*/
prepareArguments?: (args: unknown) => Static<TParameters>;
/** Execute the tool call. Throw on failure instead of encoding errors in `content`. */
execute: (
toolCallId: string,
params: Static<TParameters>,
signal?: AbortSignal,
onUpdate?: AgentToolUpdateCallback<TDetails>,
) => Promise<AgentToolResult<TDetails>>;
/**
* Per-tool execution mode override.
*/
executionMode?: ToolExecutionMode;
}Why relevant: Runtime contract documents the exception model: throw on failure, don't encode errors in content. The loop wraps the throw and synthesizes an error tool result. Tools don't have to manually format error envelopes for every provider. prepareArguments is the compatibility seam for models that emit slightly-off JSON.
// packages/agent/src/types.ts:344-354 (AgentToolResult)
export interface AgentToolResult<T> {
content: (TextContent | ImageContent)[];
details: T;
/**
* Hint that the agent should stop after the current tool batch.
* Early termination only happens when every finalized tool result in the batch sets this to true.
*/
terminate?: boolean;
}Why relevant: Three things matter: (1) content is what the model sees; (2) details is for the UI / logs / extensions and is NOT sent back to the model; (3) terminate lets a tool tell the loop "no need for another LLM turn" — but requires unanimous batch agreement (see 3_parallelization/pi.md).
// packages/agent/src/agent-loop.ts:568-684 (excerpted)
const preparedToolCall = prepareToolCallArguments(tool, toolCall);
const validatedArgs = validateToolArguments(tool, preparedToolCall);
if (config.beforeToolCall) {
const beforeResult = await config.beforeToolCall(
{ assistantMessage, toolCall, args: validatedArgs, context: currentContext },
signal,
);
if (beforeResult?.block) {
return {
kind: "immediate",
result: createErrorToolResult(beforeResult.reason || "Tool execution was blocked"),
isError: true,
};
}
}
// ...
const result = await prepared.tool.execute(
prepared.toolCall.id,
prepared.args as never,
signal,
(partialResult) => {
updateEvents.push(Promise.resolve(emit({
type: "tool_execution_update",
toolCallId: prepared.toolCall.id,
toolName: prepared.toolCall.name,
args: prepared.toolCall.arguments,
partialResult,
})));
},
);
// ...
if (config.afterToolCall) {
const afterResult = await config.afterToolCall(
{ assistantMessage, toolCall: prepared.toolCall, args: prepared.args, result, isError, context: currentContext },
signal,
);Why relevant: The complete five-step tool lifecycle. prepareArguments runs before validation; beforeToolCall runs after validation but before execution (so the hook sees typed args); onUpdate callbacks emit tool_execution_update events for streaming partial results back to the UI; afterToolCall can override the final result.
// packages/coding-agent/src/core/tools/read.ts:246-275 (excerpted)
let content: (TextContent | ImageContent)[];
// ... when the file is an image:
const resized = await resizeImage({ type: "image", data: base64, mimeType });
// ...
{ type: "image", data: resized.data, mimeType: resized.mimeType },
// ...
{ type: "image", data: base64, mimeType },Why relevant: The read tool inspects file MIME type; if it's an image, it embeds the binary as an inline ImageContent block (with automatic resize to 2000x2000 max). Real multimodal tool use — the model receives the image directly and can reason over it on its next turn.
// packages/coding-agent/src/core/tools/index.ts:83-95
export type ToolName = "read" | "bash" | "edit" | "write" | "grep" | "find" | "ls";
export const allToolNames: Set<ToolName> = new Set(["read", "bash", "edit", "write", "grep", "find", "ls"]);
export interface ToolsOptions {
read?: ReadToolOptions;
bash?: BashToolOptions;
write?: WriteToolOptions;
edit?: EditToolOptions;
grep?: GrepToolOptions;
find?: FindToolOptions;
ls?: LsToolOptions;
}// packages/coding-agent/src/core/tools/index.ts:156-163
export function createAllToolDefinitions(cwd: string, options?: ToolsOptions): Record<ToolName, ToolDef> {
return {
read: createReadToolDefinition(cwd, options?.read),
bash: createBashToolDefinition(cwd, options?.bash),
edit: createEditToolDefinition(cwd, options?.edit),
write: createWriteToolDefinition(cwd, options?.write),
grep: createGrepToolDefinition(cwd, options?.grep),
find: createFindToolDefinition(cwd, options?.find),
ls: createLsToolDefinition(cwd, options?.ls),
};
}Why relevant: Pi's built-in tool surface is small and explicitly coding-agent-oriented: file system + shell. No HTTP, no browser, no DB. Narrowness is by design — broader tooling is delegated to extensions (~70 examples in examples/extensions/).
// packages/coding-agent/src/core/tools/bash.ts:23-32
const bashSchema = Type.Object({
command: Type.String({ description: "Bash command to execute" }),
timeout: Type.Optional(Type.Number({ description: "Timeout in seconds (optional, no default timeout)" })),
});
export type BashToolInput = Static<typeof bashSchema>;Why relevant: Schema serves three purposes simultaneously: (1) runtime validation, (2) JSON Schema for the LLM, (3) BashToolInput static type for the implementation. One source of truth.
// packages/agent/src/types.ts:50-58 (BeforeToolCallResult)
export interface BeforeToolCallResult {
block?: boolean;
reason?: string;
}
// packages/agent/src/types.ts:262-281 (the two hooks)
beforeToolCall?: (context: BeforeToolCallContext, signal?: AbortSignal) => Promise<BeforeToolCallResult | undefined>;
// ...
afterToolCall?: (context: AfterToolCallContext, signal?: AbortSignal) => Promise<AfterToolCallResult | undefined>;Why relevant: beforeToolCall is the policy seam — confirmation, dangerous-command detection, dry-run, sandbox enforcement. afterToolCall is the redaction/override seam. See 10_hitl/pi.md for extensions that wire these.
// packages/coding-agent/src/core/agent-session.ts:378-427 (excerpted)
private _installAgentToolHooks(): void {
this.agent.beforeToolCall = async ({ toolCall, args }) => {
const runner = this._extensionRunner;
if (!runner.hasHandlers("tool_call")) return undefined;
// ...
return await runner.emitToolCall({
type: "tool_call",
toolName: toolCall.name,
toolCallId: toolCall.id,
input: args as Record<string, unknown>,
});
};
this.agent.afterToolCall = async ({ toolCall, args, result, isError }) => {
const runner = this._extensionRunner;
if (!runner.hasHandlers("tool_result")) return undefined;
// ...
return { content: hookResult.content, details: hookResult.details, isError: hookResult.isError ?? isError };
};
}Why relevant: Extensions don't register beforeToolCall directly — they subscribe to tool_call / tool_result events via pi.on(...). This _installAgentToolHooks wires the extension event runner into the framework hooks. Multiple extensions can subscribe; the runner aggregates their responses.
// packages/ai/src/providers/openai-responses-shared.ts:268-276
export function convertResponsesTools(tools: Tool[], options?: ConvertResponsesToolsOptions): OpenAITool[] {
const strict = options?.strict === undefined ? false : options.strict;
return tools.map((tool) => ({
type: "function",
name: tool.name,
description: tool.description,
parameters: tool.parameters as any, // TypeBox already generates JSON Schema
strict,
}));
}Why relevant: TypeBox schema goes straight through to the provider's function-call API — no zodToJsonSchema step, no runtime conversion. The comment is the point: TypeBox is JSON-Schema-native. This is why Pi picked TypeBox over Zod: zero-cost provider-facing schemas.
// packages/coding-agent/examples/extensions/subagent/index.ts:432-466 (excerpted)
export default function (pi: ExtensionAPI) {
pi.registerTool({
name: "subagent",
label: "Subagent",
description: [
"Delegate tasks to specialized subagents with isolated context.",
// ...
].join(" "),
parameters: SubagentParams,
async execute(_toolCallId, params, signal, onUpdate, ctx) { /* ... */ },
});
}Why relevant: Custom tools are first-class. An extension is an ES module exporting a default function taking ExtensionAPI; calling pi.registerTool(...) uses the same AgentTool-shape signature core tools use. Extension tools and core tools are interchangeable from the agent loop's perspective.
- TypeBox, not Zod. Most of the 2026 TypeScript agent ecosystem standardized on Zod. Pi picked TypeBox for native JSON Schema (no conversion step). Slightly less developer mindshare;
Type.Object({...})is unfamiliar to Zod users. - Tools must throw on failure — not return an error envelope. Easy to get wrong if porting from frameworks that prefer the latter. The error becomes synthesized content with a default message; the original exception is captured into
errorMessage. prepareArgumentsis a foot-cannon if used carelessly — runs before schema validation, so a sloppy shim can produce invalid args that then fail validation with a confusing error.- No tool-result streaming back to the LLM mid-execution.
onUpdate/tool_execution_updatestreams partials to the UI, but the model only sees the final result when the tool finishes. Long-running tools block the next LLM call. - Per-tool
executionMode: "sequential"poisons the whole batch (see3_parallelization/pi.md). - No tool-call telemetry standard. Per-tool latency / cost is observable in the event stream but not aggregated.
- No automatic tool retry. A tool that fails (throws) produces an error result; the model decides whether to retry, not the framework. (Distinct from provider-level retry on transient errors — see
4_reflection/pi.md.) - Built-in tool surface is narrow by design. Seven file/shell tools. Anything else (HTTP, DB, browser) is extension territory.
- ❌ MCP integration in core (Pi docs explicitly say "intentionally does not include built-in MCP")
- ❌ Built-in tool-call telemetry / metrics aggregation
- ❌ Automatic retry on tool failure (model handles, not framework)
- ❌ Streaming partial tool results into the model context mid-execution
- ❌ Zod schemas (TypeBox only)
What Pi does ship:
- ✅ Strict-schema tool parameters (TypeBox, runtime + static typing in one source)
- ✅ Parallel-by-default tool execution with per-tool sequential override
- ✅ Pre/post hooks (
beforeToolCall,afterToolCall) for confirmation, redaction, override, block - ✅ Multimodal tool results (text + image content)
- ✅ Streaming progress updates from tools to the UI (
onUpdate/tool_execution_update) - ✅
terminate: trueflag for tool-result-driven loop termination - ✅ Extension-registered tools (interchangeable with core tools)
- ✅ Provider-neutral tool schema (works across Anthropic / OpenAI / Google / Bedrock / Mistral / Azure)
- ✅ Schema preservation end-to-end (TypeBox → provider function definition, no conversion)
- ✅ Seven first-class file/shell tools plus 70+ example extensions
- ✅ Extension
tool_call/tool_resultevents wired through framework hooks