Custom Tools
Custom tools are model-callable functions that plug into the same tool execution pipeline as built-in tools.
A custom tool is a TypeScript/JavaScript module that exports a factory. The factory receives a host API (CustomToolAPI) and returns one tool or an array of tools.
What this is (and is not)
- Custom tool: callable by the model during a turn (
execute+ TypeBox schema). - Extension: lifecycle/event framework that can register tools and intercept/modify events.
- Hook: external pre/post command scripts.
- Skill: static guidance/context package, not executable tool code.
If you need the model to call code directly, use a custom tool.
Integration paths in current code
There are two active integration styles:
SDK-provided custom tools (
options.customTools)- Wrapped into agent tools via
CustomToolAdapteror extension wrappers. - Always included in the initial active tool set in SDK bootstrap.
- Wrapped into agent tools via
Filesystem-discovered modules via loader API (
discoverAndLoadCustomTools/loadCustomTools)- Exposed as library APIs in
src/extensibility/custom-tools/loader.ts. - Host code can call these to discover and load tool modules from config/provider/plugin paths.
- Exposed as library APIs in
Model tool call flow
LLM tool call
│
▼
Tool registry (built-ins + custom tool adapters)
│
▼
CustomTool.execute(toolCallId, params, onUpdate, ctx, signal)
│
├─ onUpdate(...) -> streamed partial result
└─ return result -> final tool content/detailsDiscovery locations (loader API)
discoverAndLoadCustomTools(configuredPaths, cwd, builtInToolNames) merges:
- Capability providers (
toolCapability), including:- Native pisces config (
~/.pisces/agent/tools,.pisces/tools) - Claude config (
~/.claude/tools,.claude/tools) - Codex config (
~/.codex/tools,.codex/tools) - Claude marketplace plugin cache provider
- Native pisces config (
- Installed plugin manifests (
~/.pisces/plugins/node_modules/*via plugin loader) - Explicit configured paths passed to the loader
Important behavior
- Duplicate resolved paths are deduplicated.
- Tool name conflicts are rejected against built-ins and already-loaded custom tools.
.mdand.jsonfiles are discovered as tool metadata by some providers, but the executable module loader rejects them as runnable tools.- Relative configured paths are resolved from
cwd;~is expanded.
Module contract
A custom tool module must export a function (default export preferred):
import type { CustomToolFactory } from "@oh-my-pi/pi-coding-agent";
const factory: CustomToolFactory = (pi) => ({
name: "repo_stats",
label: "Repo Stats",
description: "Counts tracked TypeScript files",
parameters: pi.typebox.Type.Object({
glob: pi.typebox.Type.Optional(pi.typebox.Type.String({ default: "**/*.ts" })),
}),
async execute(toolCallId, params, onUpdate, ctx, signal) {
onUpdate?.({
content: [{ type: "text", text: "Scanning files..." }],
details: { phase: "scan" },
});
const result = await pi.exec("git", ["ls-files", params.glob ?? "**/*.ts"], { signal, cwd: pi.cwd });
if (result.killed) {
throw new Error("Scan was cancelled");
}
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) },
};
},
onSession(event) {
if (event.reason === "shutdown") {
// cleanup resources if needed
}
},
});
export default factory;Factory return type:
CustomToolCustomTool[]Promise<CustomTool | CustomTool[]>
API surface passed to factories (CustomToolAPI)
From types.ts and loader.ts:
cwd: host working directoryexec(command, args, options?): process execution helperui: UI context (can be no-op in headless modes)hasUI:falsein non-interactive flowslogger: shared file loggertypebox: injected@sinclair/typeboxpi: injected@oh-my-pi/pi-coding-agentexportspushPendingAction(action): register a preview action for hiddenresolvetool (docs/resolve-tool-runtime.md)
Loader starts with a no-op UI context and requires host code to call setUIContext(...) when real UI is ready.
Execution contract and typing
CustomTool.execute signature:
execute(toolCallId, params, onUpdate, ctx, signal)paramsis statically typed from your TypeBox schema viaStatic<TParams>.- Runtime argument validation happens before execution in the agent loop.
onUpdateemits partial results for UI streaming.ctxincludes session/model state and anabort()helper.signalcarries cancellation.
CustomToolAdapter bridges this to the agent tool interface and forwards calls in the correct argument order.
How tools are exposed to the model
- Tools are wrapped into
AgentToolinstances (CustomToolAdapteror extension wrappers). - They are inserted into the session tool registry by name.
- In SDK bootstrap, custom and extension-registered tools are force-included in the initial active set.
- CLI
--toolscurrently validates only built-in tool names; custom tool inclusion is handled through discovery/registration paths and SDK options.
Rendering hooks
Optional rendering hooks:
renderCall(args, theme)renderResult(result, options, theme, args?)
Runtime behavior in TUI:
- If hooks exist, tool output is rendered inside a
Boxcontainer. renderResultreceives{ expanded, isPartial, spinnerFrame? }.- Renderer errors are caught and logged; UI falls back to default text rendering.
Session/state handling
Optional onSession(event, ctx) receives session lifecycle events, including:
start,switch,branch,tree,shutdownauto_compaction_start,auto_compaction_endauto_retry_start,auto_retry_endttsr_triggered,todo_reminder
Use ctx.sessionManager to reconstruct state from history when branch/session context changes.
Failures and cancellation semantics
Synchronous/async failures
- Throwing (or rejected promises) in
executeis treated as tool failure. - Agent runtime converts failures into tool result messages with
isError: trueand error text content. - With extension wrappers,
tool_resulthandlers can further rewrite content/details and even override error status.
Cancellation
- Agent abort propagates through
AbortSignaltoexecute. - Forward
signalto subprocess work (pi.exec(..., { signal })) for cooperative cancellation. ctx.abort()lets a tool request abort of the current agent operation.
onSession errors
onSessionerrors are caught and logged as warnings; they do not crash the session.
Real constraints to design for
- Tool names must be globally unique in the active registry.
- Prefer deterministic, schema-shaped outputs in
detailsfor renderer/state reconstruction. - Guard UI usage with
pi.hasUI. - Treat
.md/.jsonin tool directories as metadata, not executable modules.