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

// File path: ~/.pi/agent/extensions/todo-tool.ts
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.

FieldTypeRequiredDescription
namestringYesThe tool's unique identifier; AI calls it by this name
labelstringYesTool display name
descriptionstringYesTool function description; AI uses it to decide when to use the tool
parametersTypeBox SchemaYesType definition of tool parameters
promptSnippetstringnoOne-line summary in the Available tools section
promptGuidelinesstring[]noGuidelines appended to the Guidelines section of the system prompt
prepareArgumentsfunctionnoTransforms parameters before Schema validation, used for compatibility with old formats
executeasync functionYesThe 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:

ParameterTypeDescription
toolCallIdstringUnique ID of the tool call
paramsGeneric TParameter object validated by Schema
signalAbortSignal | undefinedCancellation signal, triggered when the user presses Escape
onUpdatefunctionCallback for sending progress updates
ctxExtensionContextExtension context, providing UI, session, and other capabilities

Return value format:

Example

// File path: ~/.pi/agent/extensions/todo-tool.ts (return value of the execute function)
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

// File path: ~/.pi/agent/extensions/todo-tool.ts (parameters field)
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

// File path: ~/.pi/agent/extensions/note-edit.ts
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

// File path: ~/.pi/agent/extensions/read-log.ts
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

// File path: ~/.pi/agent/extensions/read-logger.ts
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