Pi Agent Custom Tool Development
Register custom tools through Extension, allowing AI to call the functions you write to complete specific tasks.
Tool Registration Basics
Usagepi.registerTool()Register an AI-callable tool:
Example
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
import { Type } from "typebox";
import { StringEnum } from "@earendil-works/pi-ai";
// Define a todo list (in-memory storage)
let todos: string[] = [];
export default function (pi: ExtensionAPI) {
// Restore state from the session at startup
pi.on("session_start", async (_event, ctx) => {
todos = [];
for (const entry of ctx.sessionManager.getBranch()) {
if (entry.type === "message"
&& entry.message.role === "toolResult"
&& entry.message.toolName === "todo") {
todos = entry.message.details?.todos ?? [];
}
}
});
pi.registerTool({
name: "todo",
label: Todo List,
description: Manage the project's todo list,
// Short description, displayed in the Available tools section of the system prompt
promptSnippet: Manage project todos: list lists, add adds, done completes,
// Usage guidelines, appended to the Guidelines section of the system prompt
promptGuidelines: [
Use the todo tool for task planning; do not edit todo files directly,
],
parameters: Type.Object({
// StringEnum ensures Google API compatibility
action: StringEnum(["list", "add", "done"] as const),
text: Type.Optional(Type.String({
description: When adding: item description. When completing: item number or description
})),
}),
// Backward compatible with old parameter format
prepareArguments(args) {
if (!args || typeof args !== "object") return args;
const input = args as { action?: string; oldAction?: string };
// Map old fields to new fields
if (typeof input.oldAction === "string"
&& input.action === undefined) {
return { ...input, action: input.oldAction };
}
return args;
},
async execute(toolCallId, params, signal, onUpdate, ctx) {
if (signal?.aborted) {
return {
content: [{ type: "text", text: Operation cancelled }]
};
}
// Send progress update
onUpdate?.({
content: [{ type: "text", text:`Processing: ${params.action}` }],
});
switch (params.action) {
case "list":
return {
content: [{
type: "text",
text: todos.length === 0
? Todo list is empty
: todos.map((t, i) => `${i + 1}. ${t}`).join("\n"),
}],
details: { todos: [...todos] },
};
case "add":
if (!params.text) {
throw new Error(The add operation requires a text parameter);
}
todos.push(params.text);
// The 5th parameter ctx is the extension context; here we use it to pop a notification in the terminal
ctx.ui.notify(`Todo added: ${params.text}`, "info");
return {
content: [{
type: "text",
text:`Added: ${params.text}(total ${todos.length}item(s))`,
}],
details: { todos: [...todos] },
};
case "done":
if (!params.text) {
throw new Error(The done operation requires a text parameter);
}
const idx = todos.findIndex(
t => t.includes(params.text!)
);
if (idx === -1) {
return {
content: [{
type: "text",
text:`No matching item found: ${params.text}`
}],
details: { todos: [...todos] },
};
}
const removed = todos.splice(idx, 1)[0];
return {
content: [{
type: "text",
text:`Completed: ${removed}(remaining ${todos.length}item(s))`,
}],
details: { todos: [...todos] },
};
default:
throw new Error(Unknown operation);
}
},
});
}
Once registered, AI autonomously decides when to call the todo tool based on description and promptSnippet.
For example, when the user says "Add writing the weekly report to my todo list," AI will make the following call:
tool_call: todo
{
"action": "add",
"text": "写周报"
}
After execute runs, the text returned to AI is as follows:
待办已新增:写周报(共 1 项)
After receiving this result, AI confirms to the user in natural language, such as "Added 'writing the weekly report' to the todo list; currently 1 item in total."
Besides rebuilding state from the details in toolResult, there is another path for persistence across restarts:pi.appendEntry()。
It writes custom entries directly into the session file, combined withpi.registerEntryRenderer()they can also be rendered in the conversation history.
Custom entries do not enter the LLM context, so they do not consume tokens.
But they are saved with the session and retained in the /tree branch.
Tool Definition Explained
The registerTool configuration object contains the following fields, of which name, parameters, and execute are the core.
| Field | Type | Required | Description |
|---|---|---|---|
| name | string | Yes | The tool's unique identifier; AI calls it by this name |
| label | string | Yes | Tool display name |
| description | string | Yes | Tool function description; AI uses it to decide when to use the tool |
| parameters | TypeBox Schema | Yes | Type definition of tool parameters |
| promptSnippet | string | no | One-line summary in the Available tools section |
| promptGuidelines | string[] | no | Guidelines appended to the Guidelines section of the system prompt |
| prepareArguments | function | no | Transforms parameters before Schema validation, used for compatibility with old formats |
| execute | async function | Yes | The actual execution logic of the tool |
Each guideline in promptGuidelines mustexplicitly state the tool name。
Do not write "when using this tool..."; instead write "when using my_tool...", because AI cannot tell which tool "this" refers to.
execute Function Explained
The execute function receives the following parameters:
| Parameter | Type | Description |
|---|---|---|
| toolCallId | string | Unique ID of the tool call |
| params | Generic T | Parameter object validated by Schema |
| signal | AbortSignal | undefined | Cancellation signal, triggered when the user presses Escape |
| onUpdate | function | Callback for sending progress updates |
| ctx | ExtensionContext | Extension context, providing UI, session, and other capabilities |
Return value format:
Example
return {
// Content sent to the LLM (required)
content: [{ type: "text", text: Operation completed }],
// Custom detail data, used for state rebuilding and rendering (optional)
details: { result: "..." },
// Usage statistics for nested LLM calls (optional)
usage: nestedModelUsage,
// Termination flag: when all tools in the same batch return terminate
// Skips subsequent LLM calls (optional)
terminate: true,
};
To mark tool execution as failed, usethrow new Error(), do not attempt to set the error flag in the return value.
Only by throwing an exception can you set isError to true and notify the LLM that an error occurred.
StringEnum and Type Safety
UseStringEnuminstead of Type.Union/Type.Literal to define enum parameters:
Example
import { StringEnum } from "@earendil-works/pi-ai";
// Correct: Google API compatible
parameters: Type.Object({
action: StringEnum(["list", "add", "done"] as const),
})
// Incorrect: not Google API compatible
parameters: Type.Object({
action: Type.Union([
Type.Literal("list"),
Type.Literal("add"),
Type.Literal("done"),
]),
})
File Modification Safety Queue
If your tool modifies files, usewithFileMutationQueue()to participate in the file modification queue:
Example
import { withFileMutationQueue } from "@earendil-works/pi-coding-agent";
import { mkdir, readFile, writeFile } from "node:fs/promises";
import { dirname, resolve } from "node:path";
// The following snippet is inside the execute function of registerTool
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
// Resolve the path to an absolute path
const absolutePath = resolve(ctx.cwd, params.path);
return withFileMutationQueue(absolutePath, async () => {
// Ensure the directory exists
await mkdir(dirname(absolutePath), { recursive: true });
// Read the current content
const current = await readFile(absolutePath, "utf8");
// Apply the modification
const next = current.replace(params.oldText, params.newText);
// Write back to the file
await writeFile(absolutePath, next, "utf8");
return {
content: [{ type: "text", text:`Updated ${params.path}` }],
details: {},
};
});
}
The file modification queue ensures that concurrent modifications to the same file do not overwrite each other — when the built-in edit tool and your custom tool modify the same file at the same time, they execute in a queue.
Output Truncation
Tool output must be truncated to avoid filling up the LLM context window. The built-in limits are 50KB (about 10,000 tokens) and 2000 lines:
Example
import {
truncateHead,
formatSize,
DEFAULT_MAX_BYTES,
DEFAULT_MAX_LINES,
} from "@earendil-works/pi-coding-agent";
import { readFile } from "node:fs/promises";
import { resolve } from "node:path";
// The following snippet is inside the execute function of registerTool
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
// Read a real file to get the text to return
const output = await readFile(resolve(ctx.cwd, params.file), "utf8");
// Keep the beginning portion (suitable for file reads, search results)
const truncation = truncateHead(output, {
maxLines: DEFAULT_MAX_LINES,
maxBytes: DEFAULT_MAX_BYTES,
});
let result = truncation.content;
// Append a note line when truncated, so AI knows the content is incomplete
if (truncation.truncated) {
result += `\n\n[Output truncated: ${truncation.outputLines}/`;
result += `${truncation.totalLines}lines`;
result += `(${formatSize(truncation.outputBytes)}/`;
result += `${formatSize(truncation.totalBytes)})]`;
}
return {
content: [{ type: "text", text: result }],
};
}
For a large log file that exceeds the limits, the tool's returned text will include a truncation note at the end:
[2026-08-31 10:02:11] server started on port 3000 [2026-08-31 10:02:12] GET / 200 12ms [2026-08-31 10:02:12] GET /static/app.js 200 4ms ... [输出已截断:2000/8642 行(49.9KB/210.3KB)]
Truncation is not optional。
Oversized tool output can cause context overflow, compression failures, and degraded model performance. Always truncate tool output.
Overriding Built-in Tools
You can override built-in tools by registering tools with the same name (read, bash, powershell, edit, write, grep, find, ls).
$ pi -e ./tool-override.ts
Below, registerTool is used to override the built-in read tool, adding a log entry for each file read:
Example
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
import { Type } from "typebox";
import { readFile } from "node:fs/promises";
import { resolve } from "node:path";
export default function (pi: ExtensionAPI) {
// Override by using the same name as the built-in tool; here we only add logging to read
pi.registerTool({
name: "read",
label: "Read file",
description: "Read file contents and log access in the terminal",
parameters: Type.Object({
path: Type.String({ description: "The path of the file to read" }),
}),
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
const absolutePath = resolve(ctx.cwd, params.path);
const text = await readFile(absolutePath, "utf8");
// Additional capability: record an access log
console.log(`[read] ${params.path}(${text.length}characters)`);
// Keep the return structure consistent with the built-in read
return {
content: [{ type: "text", text }],
};
},
});
}
After loading this extension, the AI will print a [read] log line in the terminal each time it reads a file.
When overriding, renderers (renderCall/renderResult) are inherited by slot—if you omit renderCall, the built-in renderCall will still be used.
This lets you add logging or permission control to built-in tools without rewriting the UI.
But for promptSnippet and promptGuidelines, theywill notbe inherited from built-in tools; they need to be explicitly defined.
There is another more subtle constraint: your implementation must exactly match the result shape of the built-in tool, including the details field.
Take read as an example: the input parameters need to support two optional parameters, offset and limit, so that the AI can get the expected content when reading large files in a paginated manner.
The details in the return value must also match the ReadToolDetails shape of the built-in read; otherwise, the UI rendering will lack information and the session state tracking will also be affected.
The log extension example above is for demonstration only. When formally overriding the built-in read, please complete the parameters and details according to the built-in implementation.
Other extensions