Tools, interception and native delegation
Rowan wants extensions that an agent can use reliably, not merely tools that appear in a list. The obstacle is an attractive but weak interface: “do something with this text” gives the model no stable targets, no revision and no useful failure vocabulary.
Rowan adopts the patterns already earned by Seed Desk and Review Desk.
Design a composable domain interface
A useful capability often separates:
- Discover: what operations and targets exist?
- Inspect: what is the exact object and contract?
- Query: what is currently available?
- Act: make a bounded change with explicit identity and preconditions.
- Wait: observe a real asynchronous operation until a bounded deadline.
- Diagnose: explain unavailable dependencies, authority or failed progress.
These are design patterns, not six mandatory OMP method names.
The supplied synchronous Seed Desk and Review Desk tools do not implement wait or diagnose. Do not invent them in a request. When adding real background work, define a stable job ID and a truthful pending/completed/error contract before adding those operations.
A good action result says:
- which target changed;
- what changed;
- which revision now applies;
- what is available next;
- how to inspect the result;
- whether retry is safe.
It should never infer authorization from a note’s prose or from a rendered control.
The native tool signature
A registered extension tool executes as:
TypeScript API signature—reference, not a standalone file:
execute(toolCallId, params, signal, onUpdate, ctx)
The third argument is the abort signal. Some older repository examples use legacy parameter names in the wrong positions; unused parameters can hide that mistake.
Parameters may use:
pi.zod: the injected Zod-compatible omptype builder;pi.arktype: the injected native omptype builder;pi.typebox: the compatibility facade.
Use the compatible injected builders rather than assuming any arbitrary upstream schema-package version has identical behavior.
Type validation and provider wire schema generation are related but distinct. strict controls provider structured-output grammar opt-in/out; it does not replace runtime domain validation.
Tool fields, without conflation
| Fields | Meaning |
|---|---|
name, label, description | Machine identity, human label and model-facing contract |
parameters | Runtime/wire parameter schema |
execute | Actual implementation |
hidden | Excluded from normal initial inclusion unless explicitly selected |
defaultInactive | Registered, but not automatically enabled; the extension owns later activation |
loadMode | Presentation of an enabled tool: essential or discoverable only |
deferrable | The tool may stage changes requiring explicit resolve/discard; separate from discovery |
approval | Static or argument-dependent tier/policy declaration |
strict | Provider strict-grammar choice; explicit false is meaningful |
mcpServerName, mcpToolName | MCP provenance/search metadata |
shellEnv | Environment contribution for the supported user-shell seam |
sourcePath | Originating custom-tool file metadata |
onSession | Host-dispatched tool lifecycle callback |
renderCall, renderResult | Optional TUI presentation |
There is no loadMode: "deferred" in this source.
Discoverable tools are kept off the normal top-level schema where the host’s discovery transport permits it—through xd:// mounting or tool search. That is presentation, not disabled authority. An essential tool stays top-level.
getActiveTools() is wired by the supplied TUI/ACP adapters to the enabled set, including discoverable tools. It is not necessarily identical to agent.state.tools, the directly presented top-level array.
setActiveTools(names) is asynchronous. Await it. Unknown names are ignored by the session setter; inspect the resulting set rather than assuming every requested name exists.
Content, details and streaming
Return:
content: text/image blocks intended for model-visible results;- optional
details: structured host/rendering metadata; - optional
isError: a nonthrowing failure indicator.
Put decision-critical machine fields in content too when the model needs them. Review Desk’s JSON text is one approach.
onUpdate() emits partial results. It is progress, not a commit receipt. renderResult() receives expanded, isPartial and optional spinnerFrame; native extension renderers may also receive original arguments as a fourth parameter.
A tool should:
- check an already-aborted signal;
- pass cancellation into subprocess/network work;
- validate again before a side effect when asynchronous work intervenes;
- distinguish cancellation before and after commit.
Milestone: a standalone custom tool with real work
Rowan has an existing tool-only package. A custom-tool factory is appropriate here; it returns a tool instead of calling registerTool().
This complete exercise requires Git and a Git working tree. It counts tracked TypeScript files; it does not contact a remote.
Complete TypeScript exercise—save as .omp/tools/workbook-repo-stats.ts in a test repository:
import type { CustomToolFactory } from "@oh-my-pi/pi-coding-agent";
const factory: CustomToolFactory = pi => ({
name: "workbook_repo_stats",
label: "Workbook repository stats",
description: "Count tracked TypeScript files in the current Git working tree.",
approval: "exec",
loadMode: "essential",
parameters: pi.zod.object({
glob: pi.zod.string().optional(),
}),
async execute(_callId, params, onUpdate, ctx, signal) {
signal?.throwIfAborted();
onUpdate?.({
content: [{ type: "text", text: "Reading the tracked file list." }],
});
const result = await pi.exec("git", ["ls-files", "-z", "--", params.glob ?? "*.ts"], {
cwd: ctx.sessionManager.getCwd(),
signal,
timeout: 5000,
});
if (result.killed) throw new Error("Tracked-file query was cancelled.");
if (result.code !== 0) throw new Error(result.stderr || "git ls-files failed.");
const files = result.stdout.split("\0").filter(Boolean);
const details = { count: files.length, files };
return {
content: [{ type: "text", text: JSON.stringify(details) }],
details,
};
},
});
export default factory;
Model tool arguments—call workbook_repo_stats after discovery/loading:
{}
The legacy custom-tool order is:
TypeScript API signature—standalone custom-tool reference:
execute(toolCallId, params, onUpdate, ctx, signal)
The SDK bridge converts that order to the native extension order.
The conservative exec approval tier reflects launching a program. Host policy may require approval even though this particular Git operation reads metadata.
pi.exec() takes a program plus argv, not an automatically interpreted shell expression. Its options are signal, timeout in milliseconds and cwd; results are stdout, stderr, code and killed.
For native extensions, pi.exec() defaults to the factory-bound cwd. Pass ctx.cwd explicitly when you need the current workspace after a move.
Expected checkpoint: the result’s count matches the returned file array. Outside a Git repository, the tool fails instead of returning a fake zero.
Exercise: abort during a slower subprocess version.
Checkpoint: the signal reaches the subprocess and killed is handled; no success result is fabricated.
Standalone-tool boundaries
The loader also discovers metadata records in some tool directories. A .md or .json tool record is not automatically an executable factory.
Standalone custom-tool name conflicts are rejected against built-ins and previously loaded custom tools. That differs from extension re-registration, which can intentionally replace a built-in.
The legacy custom-tool type includes compatibility members that do not all survive the ordinary bridge. In particular, its approval-formatting callback is not propagated through the supplied customToolToDefinition() path, and that bridge does not forward original arguments to the legacy result renderer’s optional fourth argument.
SDK customTools uses the legacy contract; SDK toolDefinitions uses the native contract. Restricted sessions exclude ambient extensions/custom tools. Explicit SDK tools require allowRestrictedCustomTools: true and selection in toolNames when used with restrictToolNames: true.
Milestone: intercept a tool without rewriting its domain
Rowan wants first to be a deliberate alias for the reed note when inspecting through field_notes, and wants an oversized query refused.
This is an additional exercise, not a change to the downloaded entry.
Complete TypeScript exercise—save as field-guard.ts:
import type { ExtensionAPI } from "@oh-my-pi/pi-coding-agent";
export default function fieldGuard(pi: ExtensionAPI): void {
pi.on("tool_call", event => {
if (event.toolName !== "field_notes") return;
if (event.input.op === "inspect" && event.input.value === "first") {
return { input: { op: "inspect", value: "reed" } };
}
if (
event.input.op === "query" &&
typeof event.input.value === "string" &&
event.input.value.length > 80
) {
return { block: true, reason: "Field-note queries are limited to 80 characters." };
}
});
pi.on("tool_result", event => {
if (event.toolName !== "field_notes" || !event.isError) return;
return {
content: [
...event.content,
{ type: "text", text: "Use field_notes discover before choosing an inspection ID." },
],
isError: true,
};
});
}
Terminal shell—load the real domain and the new middleware:
omp --no-extensions -e "$EXAMPLES/package-lab/single/field-notes.ts" -e ./field-guard.ts
Model tool arguments—call field_notes:
{"op":"inspect","value":"first"}
Expected checkpoint: the effective tool input becomes inspection of reed.
For model-issued calls, tool_call runs at argument-preparation time. Replacement input is revalidated and becomes the input used for scheduling, execution events, persisted assistant tool-call arguments and approval.
For direct or nested dispatches not passing through that agent-loop preparation path, the wrapper applies the replacement before its approval gate. Do not assume a direct tool.execute() SDK call reproduces the loop’s schema revalidation.
All tool_call handlers see the original normalized event view, not a middleware chain of earlier rewrites. The current runner retains the last returned result object unless a block short-circuits; a later empty object can discard an earlier rewrite. Coordinate policy handlers instead of assuming field-by-field merging.
Normalized event input may contain derived fields, such as hashline edit path/paths, that are not valid execution parameters. Return the tool’s actual input shape, not a copied gate-only view. Computer provider calls have a synthetic view and do not apply these input replacements.
A revised nested xd:// call forfeits an outer approval bypass and faces the appropriate gate again.
The slash command /field-notes first does not traverse this tool middleware. Put mandatory shared policy in the shared domain if both entrances must enforce it.
Milestone: delegate to the original built-in
Rowan next wants to add logging around one ordinary file write without reimplementing native snapshots and editing bookkeeping.
ctx.invokeTool() is available only when the registered tool shadows a native built-in of the same name. It is not a general “invoke arbitrary tool” API.
This deliberately narrow exercise replaces write and permits only workbook-note.txt. Use it in a scratch workspace; it will refuse other writes.
Complete TypeScript exercise—save as one-file-write.ts:
import type { ExtensionAPI } from "@oh-my-pi/pi-coding-agent";
export default function oneFileWrite(pi: ExtensionAPI): void {
const parameters = pi.zod.object({
path: pi.zod.string(),
content: pi.zod.string(),
});
pi.registerTool<typeof parameters>({
name: "write",
label: "Workbook one-file write",
description: "Write workbook-note.txt through the native write implementation. Other targets are refused.",
approval: "write",
loadMode: "essential",
parameters,
async execute(_callId, params, signal, _onUpdate, ctx) {
signal?.throwIfAborted();
if (params.path !== "workbook-note.txt") {
throw new Error("This exercise permits only workbook-note.txt.");
}
if (!ctx.invokeTool) {
throw new Error("The native write implementation is unavailable in this host.");
}
pi.logger.debug("Delegating workbook file write", { path: params.path });
return await ctx.invokeTool(params);
},
});
}
The bare delegate inherits the caller’s signal, progress callback and relevant native tool context. The native call is not re-gated; the outer same-tool call already passed approval. Delegation depth is guarded.
That narrow delegation mechanism is not a restriction on arbitrary JavaScript imported by the extension. Do not describe it as a sandbox.
Exercise: rename the tool to workbook_write without changing the delegate.
Checkpoint: it no longer shadows write, so ctx.invokeTool is absent. Share a domain function or use an explicit host adapter; do not invent an arbitrary-target overload.
Approval, result transforms and session hooks
Approval declarations can be:
read,writeorexec;- an object with
tier,reason,override,policyand optionalpolicyKey; - a function of arguments returning such a decision.
The current tier comparison is:
always-askautomatically admits read-tier operations;writeadmits read/write tiers;yoloadmits all tiers by default.
The name always-ask therefore does not mean every read prompts.
Explicit deny policies remain important. override: true alone is not an unbypassable prompt in yolo mode. These host policies must not replace a domain-specific human grant.
tool_result patches content, details and isError in sequence; later handlers see earlier changes. It cannot undo an external side effect.
One current wrapper detail matters for diagnostics: its event isError is initialized from a thrown execution error. A domain refusal, or a nonthrowing result’s own failure flag, should not be inferred solely from that event flag. Seed Desk’s details.ok is intentionally explicit. Test nonthrowing failures before writing result middleware that could erase their meaning.
ToolDefinition.onSession has reasons start, switch, branch, tree and shutdown, with previousSessionFile. It is host-dispatched; the TUI controller has an explicit dispatcher. The standalone custom-tool bridge also maps additional reliability events. For cross-mode domain lifecycle behavior, use and test explicit pi.on(...) handlers as the principal examples do.
shellEnv receives command, cwd and a copy of the shell environment. In the supplied BashRunner, the registered bash definition’s hook contributes environment values when useUserShell is enabled. It is not an all-subprocess environment interceptor and does not automatically affect pi.exec().
Source, snapshot 2026-08-29: packages/coding-agent/src/extensibility/extensions/types.ts, ToolDefinition; wrapper.ts; runner.ts, emitToolCall, emitToolResult, invokeNativeTool; packages/coding-agent/src/sdk.ts, customToolToDefinition; packages/coding-agent/src/session/bash-runner.ts; packages/coding-agent/src/exec/exec.ts; packages/coding-agent/src/tools/approval.ts.
Extensions inside those boundaries · Source chapter: extensions/tools-interception-and-native-delegation. Original evidence remains scoped to its recorded snapshot.