When to write one
Reach for a custom tool when the model needs to do something specific to your project: query an internal API, run a domain check, mutate a remote system. If you just want to expose an off-the-shelf integration, use an MCP server instead.
Where it goes
Drop a TypeScript module at one of:
-
~/.omp/agent/tools/<name>/index.ts— user scope -
.omp/tools/<name>/index.ts— project scope
.claude/tools/ and .codex/tools/ are also picked up. The tool’s registered name comes from the name field the factory returns. Plain .md and .json in the same folder are treated as metadata, not modules.
Skeleton
Default-export a factory. The factory receives a host API (pi) with an injected Zod instance (pi.zod); params is statically typed from the schema.
import type { CustomToolFactory } from "@oh-my-pi/pi-coding-agent"
const factory: CustomToolFactory = (pi) => ({
name: "repo_stats",
label: "Repo Stats",
description: "Count tracked files matching a glob",
parameters: pi.zod.object({
glob: pi.zod.string().optional().default("**/*.ts"),
}),
async execute(_toolCallId, params, onUpdate, _ctx, signal) {
onUpdate?.({
content: [{ type: "text", text: `Listing ${params.glob ?? "**/*.ts"}` }],
})
const result = await pi.exec("git", ["ls-files", params.glob ?? "**/*.ts"], {
signal,
cwd: pi.cwd,
})
if (result.code !== 0) {
throw new Error(result.stderr || "git ls-files failed")
}
const files = result.stdout.split("\n").filter(Boolean)
return {
content: [{ type: "text", text: `Found ${files.length} files` }],
details: { count: files.length, sample: files.slice(0, 10) },
}
},
})
export default factoryFactory fields
| Field | Purpose |
|---|---|
name | Tool name the model calls. Must not collide with a built-in or another custom tool. |
label | Human-readable label for the TUI. |
description | What the model sees when deciding whether to call it. Be specific about triggers. |
parameters | Zod schema (pi.zod; TypeBox-style schemas are also accepted). Drives validation and typing of params. |
execute | (toolCallId, params, onUpdate, ctx, signal) => Promise<ToolResult>. Forward signal to subprocesses so cancellation propagates. |
renderCall / renderResult | Optional. Custom TUI renderers for the call card and result. |
Streaming output
Call onUpdate(partial) from inside execute to push progress to the TUI before the final return. The model sees the final content only; onUpdate is for the user.
Return shape
Return an AgentToolResult. content is what the model reads; details stays out of the prompt.
return {
content: [
{ type: "text", text: "Done." },
{ type: "image", mimeType: "image/png", data: pngBase64 },
],
details: { /* arbitrary JSON, surfaced to the user, not the model */ },
isError: false,
};or: false,
};text blocks become inline context. image blocks ride into vision-capable models.
Loading and collisions
Name collisions are rejected at load time — against built-ins and against any already-loaded custom tool. There is no override flag. Built-ins always win. Run omp -p '/extensions' to see what loaded and what was rejected.