piagent_
← ~/guides

Custom Tools and SDK Integration

Last updated: Aug 11, 2026

$ pi –tool

TL;DR. Pi exposes two paths to give the LLM new tools: inside an extension, call pi.registerTool({ name, label, description, parameters, execute }) where parameters is a typebox Type.Object schema and execute returns { content: [{ type: 'text', text }], details: {} }; inside a Node.js application embedding pi through the SDK, define a tool with the standalone defineTool() helper and pass it via customTools: [...] on the SDK options. Custom tools are extension-level concerns — there is no built-in MCP server adapter in the current docs, so third-party protocol support comes from community extensions. Both paths share the same permission model and the same output-truncation rule.

Every tool pi can call, whether built-in (read, write, edit, bash, grep, find, ls) or registered by an extension, follows the same shape on the wire. If you can write one tool, you can write any of them; the difference between pi.registerTool and defineTool is mostly where the call lives.

When to use each path

Pick extensions when the tool belongs with the user’s interactive pi install — they can pi install git:..., share it on npm, or load it from ~/.pi/agent/extensions/. Extensions are TypeScript modules that run inside the pi process with full system permissions, so they’re appropriate for tools that need to touch the filesystem, run subprocesses, or call out to the network.

Pick the SDK when you are embedding pi into a Node.js application — a Slack bot, a CI step, an internal web service — and want to scope the agent’s tool surface to exactly what your application exposes. The SDK uses the same primitives but lets you define tools inline without shipping an extension package.

The mechanisms are not interchangeable. An extension can registerTool but cannot be defineTool-only; an SDK application defines tools via customTools and does not run as an extension. The other side of the line is the execution environment: extensions run wherever the user runs pi; SDK-embedded pi runs wherever you run the host application.

The minimal registerTool example

import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
import { Type } from "typebox";

export default function (pi: ExtensionAPI) {
  pi.registerTool({
    name: "greet",
    label: "Greet",
    description: "Greet someone by name",
    parameters: Type.Object({
      name: Type.String({ description: "Name to greet" }),
    }),
    async execute(_id, params) {
      return {
        content: [{ type: "text", text: `Hello, ${params.name}!` }],
        details: {},
      };
    },
  });
}

Save the file as greet.ts and run pi -e ./greet.ts to load it as a one-off extension. Inside the TUI, the LLM can now call greet with a name parameter. The tool appears in the model’s available-tool list the next time the prompt is sent.

The full signature is async execute(toolCallId, params, signal, onUpdate, ctx). The five arguments let you handle cancellation (signal), stream progress (onUpdate), and read context (ctx). For the common case of a one-shot synchronous tool, _id and the trailing arguments are optional in the type sense but conventional in practice — match the shape even when you don’t use them.

The parameters schema

parameters is a typebox schema, not a plain object. The schema is what the LLM sees when it decides whether to call your tool, so the descriptions on each field matter more than usual.

import { Type } from "typebox";

parameters: Type.Object({
  query: Type.String({ description: "Search query text" }),
  limit: Type.Optional(Type.Integer({ minimum: 1, maximum: 50, default: 10 })),
  format: Type.Optional(Type.Union([
    Type.Literal("json"),
    Type.Literal("text"),
  ])),
})

A String enum that has to render correctly across all providers should use StringEnum from @earendil-works/pi-ai, not Type.Union([Type.Literal(...), ...]). The Google Generative AI API rejects plain string unions, and StringEnum produces the dialect Google expects. This is a documentation footnote but a sharp edge for anyone whose tools need to work across the full provider matrix.

What the execute return looks like

The shape is fixed: { content: [...], details?: {} }. The content array carries what the model sees in its transcript; details is opaque to the model and is for tool-to-tool plumbing or your own UI.

async execute(_id, params, signal, onUpdate, ctx) {
  const text = await fetchSomething(params.query, { signal });
  return {
    content: [{ type: "text", text }],
    details: { fetchedAt: new Date().toISOString() },
  };
}

Two rules that look optional but aren’t:

  1. Truncate long output. Tool output that runs past a few thousand tokens will be summarized or dropped before reaching the model. Wrap large results, paginate, or write the full output to a file and return a short pointer.

  2. Throw to signal error. Returning a value from execute is always a success — even if the value looks like an error message. To flag a failed tool call, throw; the framework sets isError on the tool result and the model sees it as a failure rather than a “successful” string of error text.

isError can also be set explicitly on the return value when you want to communicate a soft failure without an exception. The two paths exist for different error-handling styles; pick one per tool and stick with it.

Mutating tools and the file mutation queue

Tools that mutate files in the working directory — writers, formatters, code generators — should route their writes through withFileMutationQueue. The queue serializes edits against pi’s own edit tool, so concurrent tool calls don’t trample each other or fight the in-session file watcher.

async execute(_id, params, signal, onUpdate, ctx) {
  return ctx.withFileMutationQueue(async () => {
    await fs.writeFile(params.path, params.content, "utf-8");
    return {
      content: [{ type: "text", text: `wrote ${params.path}` }],
      details: {},
    };
  });
}

Skip the queue and you’ll find subtle race conditions when the LLM chains your tool with edit in the same turn. The queue is also how pi diffs the result before presenting it to the user as an inline change.

SDK: the standalone defineTool + customTools path

When embedding pi through the SDK, the entry point is defineTool() from @earendil-works/pi-coding-agent. The function takes the same { name, label, description, parameters, execute } shape and returns a tool definition that the SDK understands. Pass it via customTools on the options object:

import { createAgentSession, defineTool } from "@earendil-works/pi-coding-agent";
import { Type } from "typebox";

const greet = defineTool({
  name: "greet",
  label: "Greet",
  description: "Greet someone by name",
  parameters: Type.Object({
    name: Type.String({ description: "Name to greet" }),
  }),
  async execute(_id, params) {
    return {
      content: [{ type: "text", text: `Hello, ${params.name}!` }],
      details: {},
    };
  },
});

const session = await createAgentSession({
  customTools: [greet],
  // ...other options
});

defineTool() and pi.registerTool({ ... }) produce the same shape on the wire, so parameters and execute follow identical rules in both paths. Inline pi.registerTool({ ... }) (inside an extension) infers parameter types correctly because TypeScript sees the schema directly; defineTool() does the same on the SDK side.

Permissions: full system access, by design

Extensions and SDK-embedded tools run with the host process’s full system permissions. There is no permission prompt per tool call, no sandbox, and no review step before a tool executes. The README is explicit about this: “Extensions run with your full system permissions and can execute arbitrary code. Only install from sources you trust.”

The implication for tool authors: treat every tool you ship as if the user will run it on production data. State the side effects in the description — “reads files”, “calls https://example.com”, “writes to disk” — and write a README that lists the network endpoints and the file paths your tool touches. For SDK embeddings, the boundary is your application’s own trust model: if the host process can read the database, so can any tool you register.

There is no way to declare a tool as read-only or to ask for permission at execution time. If that level of confinement matters, run pi in a container or a VM and treat the boundary as the host.

MCP, model context protocol, and third-party adapters

Pi’s own docs cover extensions and the SDK but do not document an MCP server adapter, a tools.md page, or an mcp.md page as of v0.84.x. pi-mcp-adapter exists as a community-maintained extension, not a built-in module — it appears on the resource map and provides a JSON-RPC bridge to MCP servers, but its API surface is not part of pi’s stable docs and can change.

If you need to expose MCP-style tools today, treat the choice as you would any third-party adapter:

  • Pull in pi-mcp-adapter from the resource map and verify it works on your target pi version before depending on it.
  • For new code, prefer the documented registerTool / defineTool surface so you’re on the supported path.
  • If you’re writing the MCP side, the JSON-RPC schema you implement is the standard one — your adapter choice doesn’t change what your server has to expose.

The absence of a built-in MCP adapter is a real limitation when porting a Claude Code or Codex workflow that leans on MCP for everything. The community adapter closes most of the gap, but expect to read its source rather than trust its docs to be exhaustive.

Putting it together: a small but realistic tool

A search-by-glob tool that returns file contents matching a pattern, registered through both paths for comparison:

// Extension path
export default function (pi: ExtensionAPI) {
  pi.registerTool({
    name: "ripgrep",
    label: "ripgrep",
    description: "Search file contents with ripgrep",
    parameters: Type.Object({
      pattern: Type.String({ description: "regex pattern" }),
      path: Type.String({ description: "directory to search" }),
    }),
    async execute(_id, params, signal) {
      const proc = Bun.spawn(["rg", "--json", params.pattern, params.path], {
        signal,
      });
      const text = await new Response(proc.stdout).text();
      return {
        content: [{ type: "text", text: text.slice(0, 8000) }],
        details: { truncated: text.length > 8000 },
      };
    },
  });
}
// SDK path
import { createAgentSession, defineTool } from "@earendil-works/pi-coding-agent";
import { Type } from "typebox";

const ripgrep = defineTool({
  name: "ripgrep",
  label: "ripgrep",
  description: "Search file contents with ripgrep",
  parameters: Type.Object({
    pattern: Type.String({ description: "regex pattern" }),
    path: Type.String({ description: "directory to search" }),
  }),
  async execute(_id, params, signal) {
    const proc = Bun.spawn(["rg", "--json", params.pattern, params.path], { signal });
    const text = await new Response(proc.stdout).text();
    return {
      content: [{ type: "text", text: text.slice(0, 8000) }],
      details: { truncated: text.length > 8000 },
    };
  },
});

const session = await createAgentSession({ customTools: [ripgrep] });

The body is identical; only the registration surface differs. That’s the whole story: pick a path based on whether your tool ships to users as a package or runs inline inside your app.

Further reading

  • Extensions — events, custom tools, slash commands, project trust
  • SDK — embedding pi in Node.js applications, customTools, session lifecycle
  • Packages — install extensions and skills via npm or git
  • RPC — driving pi from non-Node hosts over JSON-RPC
  • JSON output — structured output from the SDK
  • Skills — reusable skill modules compatible with multiple agents
  • @earendil-works/pi-coding-agent — SDK source, defineTool, session types
  • @earendil-works/pi-aiStringEnum and other AI utilities
  • pi-mcp-adapter — community MCP bridge (not built-in)