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 factory

Factory fields

FieldPurpose
nameTool name the model calls. Must not collide with a built-in or another custom tool.
labelHuman-readable label for the TUI.
descriptionWhat the model sees when deciding whether to call it. Be specific about triggers.
parametersZod 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 / renderResultOptional. 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.

  • MCP — expose tools served by an external process.

  • Hooks — intercept tool calls and results instead of adding new ones.

  • Plugins — bundle custom tools with skills, commands, and MCP configs.\n

Ask Docs

AI assistant to help answer questions about the documentation. Answers are read-only and cite docs/source.

Hi! How can I help you with the documentation today? Answers are read-only and cite docs/source.

Ctrl+Enter to send