## Tools\,&#32;interception and native delegation

Rowan wants extensions that an agent can use reliably\,&#32;not merely tools that appear in a list\.&#32;The obstacle is an attractive but weak interface\:&#32;“do something with this text” gives the model no stable targets\,&#32;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\:

1. **Discover\:**&#32;what operations and targets exist\?
2. **Inspect\:**&#32;what is the exact object and contract\?
3. **Query\:**&#32;what is currently available\?
4. **Act\:**&#32;make a bounded change with explicit identity and preconditions\.
5. **Wait\:**&#32;observe a real asynchronous operation until a bounded deadline\.
6. **Diagnose\:**&#32;explain unavailable dependencies\,&#32;authority or failed progress\.

These are design patterns\,&#32;not six mandatory OMP method names\.

The supplied synchronous Seed Desk and Review Desk tools do not implement&#32;`wait`&#32;or&#32;`diagnose`\.&#32;Do not invent them in a request\.&#32;When adding real background work\,&#32;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\,&#32;not a standalone file\:**

~~~ts
execute(toolCallId, params, signal, onUpdate, ctx)
~~~

The third argument is the abort signal\.&#32;Some older repository examples use legacy parameter names in the wrong positions\;&#32;unused parameters can hide that mistake\.

Parameters may use\:

- `pi.zod`\:&#32;the injected Zod\-compatible omptype builder\;
- `pi.arktype`\:&#32;the injected native omptype builder\;
- `pi.typebox`\:&#32;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\.&#32;`strict`&#32;controls provider structured\-output grammar opt\-in\/out\;&#32;it does not replace runtime domain validation\.

### Tool fields\,&#32;without conflation

| Fields | Meaning |
| --- | --- |
| `name`\,&#32;`label`\,&#32;`description` | Machine identity\,&#32;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\,&#32;but not automatically enabled\;&#32;the extension owns later activation |
| `loadMode` | Presentation of an enabled tool\:&#32;**`essential`&#32;or&#32;`discoverable`&#32;only** |
| `deferrable` | The tool may stage changes requiring explicit resolve\/discard\;&#32;separate from discovery |
| `approval` | Static or argument\-dependent tier\/policy declaration |
| `strict` | Provider strict\-grammar choice\;&#32;explicit false is meaningful |
| `mcpServerName`\,&#32;`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`\,&#32;`renderResult` | Optional TUI presentation |

There is no&#32;`loadMode: "deferred"`&#32;in this source\.

Discoverable tools are kept off the normal top\-level schema where the host’s discovery transport permits it—through&#32;`xd://`&#32;mounting or tool search\.&#32;That is presentation\,&#32;not disabled authority\.&#32;An essential tool stays top\-level\.

`getActiveTools()`&#32;is wired by the supplied TUI\/ACP adapters to the enabled set\,&#32;including discoverable tools\.&#32;It is not necessarily identical to&#32;`agent.state.tools`\,&#32;the directly presented top\-level array\.

`setActiveTools(names)`&#32;is asynchronous\.&#32;Await it\.&#32;Unknown names are ignored by the session setter\;&#32;inspect the resulting set rather than assuming every requested name exists\.

### Content\,&#32;details and streaming

Return\:

- `content`\:&#32;text\/image blocks intended for model\-visible results\;
- optional&#32;`details`\:&#32;structured host\/rendering metadata\;
- optional&#32;`isError`\:&#32;a nonthrowing failure indicator\.

Put decision\-critical machine fields in content too when the model needs them\.&#32;Review Desk’s JSON text is one approach\.

`onUpdate()`&#32;emits partial results\.&#32;It is progress\,&#32;not a commit receipt\.&#32;`renderResult()`&#32;receives&#32;`expanded`\,&#32;`isPartial`&#32;and optional&#32;`spinnerFrame`\;&#32;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\:&#32;a standalone custom tool with real work

Rowan has an existing tool\-only package\.&#32;A custom\-tool factory is appropriate here\;&#32;it returns a tool instead of calling&#32;`registerTool()`\.

This complete exercise requires Git and a Git working tree\.&#32;It counts tracked TypeScript files\;&#32;it does not contact a remote\.

**Complete TypeScript exercise—save as&#32;`.omp/tools/workbook-repo-stats.ts`&#32;in a test repository\:**

~~~ts
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&#32;`workbook_repo_stats`&#32;after discovery\/loading\:**

~~~json
{}
~~~

The legacy custom\-tool order is\:

**TypeScript API signature—standalone custom\-tool reference\:**

~~~ts
execute(toolCallId, params, onUpdate, ctx, signal)
~~~

The SDK bridge converts that order to the native extension order\.

The conservative&#32;`exec`&#32;approval tier reflects launching a program\.&#32;Host policy may require approval even though this particular Git operation reads metadata\.

`pi.exec()`&#32;takes a program plus argv\,&#32;not an automatically interpreted shell expression\.&#32;Its options are&#32;`signal`\,&#32;`timeout`&#32;in milliseconds and&#32;`cwd`\;&#32;results are&#32;`stdout`\,&#32;`stderr`\,&#32;`code`&#32;and&#32;`killed`\.

For native extensions\,&#32;`pi.exec()`&#32;defaults to the factory\-bound cwd\.&#32;Pass&#32;`ctx.cwd`&#32;explicitly when you need the current workspace after a move\.

**Expected checkpoint\:**&#32;the result’s count matches the returned file array\.&#32;Outside a Git repository\,&#32;the tool fails instead of returning a fake zero\.

**Exercise\:**&#32;abort during a slower subprocess version\.

**Checkpoint\:**&#32;the signal reaches the subprocess and&#32;`killed`&#32;is handled\;&#32;no success result is fabricated\.

#### Standalone\-tool boundaries

The loader also discovers metadata records in some tool directories\.&#32;A&#32;`.md`&#32;or&#32;`.json`&#32;tool record is not automatically an executable factory\.

Standalone custom\-tool name conflicts are rejected against built\-ins and previously loaded custom tools\.&#32;That differs from extension re\-registration\,&#32;which can intentionally replace a built\-in\.

The legacy custom\-tool type includes compatibility members that do not all survive the ordinary bridge\.&#32;In particular\,&#32;its approval\-formatting callback is not propagated through the supplied&#32;`customToolToDefinition()`&#32;path\,&#32;and that bridge does not forward original arguments to the legacy result renderer’s optional fourth argument\.

SDK&#32;`customTools`&#32;uses the legacy contract\;&#32;SDK&#32;`toolDefinitions`&#32;uses the native contract\.&#32;Restricted sessions exclude ambient extensions\/custom tools\.&#32;Explicit SDK tools require&#32;`allowRestrictedCustomTools: true`&#32;and selection in&#32;`toolNames`&#32;when used with&#32;`restrictToolNames: true`\.

### Milestone\:&#32;intercept a tool without rewriting its domain

Rowan wants&#32;`first`&#32;to be a deliberate alias for the reed note when inspecting through&#32;`field_notes`\,&#32;and wants an oversized query refused\.

This is an additional exercise\,&#32;not a change to the downloaded entry\.

**Complete TypeScript exercise—save as&#32;`field-guard.ts`\:**

~~~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\:**

~~~sh
omp --no-extensions -e "$EXAMPLES/package-lab/single/field-notes.ts" -e ./field-guard.ts
~~~

**Model tool arguments—call&#32;`field_notes`\:**

~~~json
{"op":"inspect","value":"first"}
~~~

**Expected checkpoint\:**&#32;the effective tool input becomes inspection of&#32;`reed`\.

For model\-issued calls\,&#32;`tool_call`&#32;runs at argument\-preparation time\.&#32;Replacement input is revalidated and becomes the input used for scheduling\,&#32;execution events\,&#32;persisted assistant tool\-call arguments and approval\.

For direct or nested dispatches not passing through that agent\-loop preparation path\,&#32;the wrapper applies the replacement before its approval gate\.&#32;Do not assume a direct&#32;`tool.execute()`&#32;SDK call reproduces the loop’s schema revalidation\.

All&#32;`tool_call`&#32;handlers see the original normalized event view\,&#32;not a middleware chain of earlier rewrites\.&#32;The current runner retains the last returned result object unless a block short\-circuits\;&#32;a later empty object can discard an earlier rewrite\.&#32;Coordinate policy handlers instead of assuming field\-by\-field merging\.

Normalized event input may contain derived fields\,&#32;such as hashline edit&#32;`path`\/`paths`\,&#32;that are not valid execution parameters\.&#32;Return the tool’s actual input shape\,&#32;not a copied gate\-only view\.&#32;Computer provider calls have a synthetic view and do not apply these input replacements\.

A revised nested&#32;`xd://`&#32;call forfeits an outer approval bypass and faces the appropriate gate again\.

The slash command&#32;`/field-notes first`&#32;does&#32;**not**&#32;traverse this tool middleware\.&#32;Put mandatory shared policy in the shared domain if both entrances must enforce it\.

### Milestone\:&#32;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()`&#32;is available only when the registered tool shadows a native built\-in of the&#32;**same name**\.&#32;It is not a general “invoke arbitrary tool” API\.

This deliberately narrow exercise replaces&#32;`write`&#32;and permits only&#32;`workbook-note.txt`\.&#32;Use it in a scratch workspace\;&#32;it will refuse other writes\.

**Complete TypeScript exercise—save as&#32;`one-file-write.ts`\:**

~~~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\,&#32;progress callback and relevant native tool context\.&#32;The native call is not re\-gated\;&#32;the outer same\-tool call already passed approval\.&#32;Delegation depth is guarded\.

That narrow delegation mechanism is not a restriction on arbitrary JavaScript imported by the extension\.&#32;Do not describe it as a sandbox\.

**Exercise\:**&#32;rename the tool to&#32;`workbook_write`&#32;without changing the delegate\.

**Checkpoint\:**&#32;it no longer shadows&#32;`write`\,&#32;so&#32;`ctx.invokeTool`&#32;is absent\.&#32;Share a domain function or use an explicit host adapter\;&#32;do not invent an arbitrary\-target overload\.

### Approval\,&#32;result transforms and session hooks

Approval declarations can be\:

- `read`\,&#32;`write`&#32;or&#32;`exec`\;
- an object with&#32;`tier`\,&#32;`reason`\,&#32;`override`\,&#32;`policy`&#32;and optional&#32;`policyKey`\;
- a function of arguments returning such a decision\.

The current tier comparison is\:

- `always-ask`&#32;automatically admits read\-tier operations\;
- `write`&#32;admits read\/write tiers\;
- `yolo`&#32;admits all tiers by default\.

The name&#32;`always-ask`&#32;therefore does not mean every read prompts\.

Explicit deny policies remain important\.&#32;`override: true`&#32;alone is not an unbypassable prompt in yolo mode\.&#32;These host policies must not replace a domain\-specific human grant\.

`tool_result`&#32;patches&#32;`content`\,&#32;`details`&#32;and&#32;`isError`&#32;in sequence\;&#32;later handlers see earlier changes\.&#32;It cannot undo an external side effect\.

One current wrapper detail matters for diagnostics\:&#32;its event&#32;`isError`&#32;is initialized from a thrown execution error\.&#32;A domain refusal\,&#32;or a nonthrowing result’s own failure flag\,&#32;should not be inferred solely from that event flag\.&#32;Seed Desk’s&#32;`details.ok`&#32;is intentionally explicit\.&#32;Test nonthrowing failures before writing result middleware that could erase their meaning\.

`ToolDefinition.onSession`&#32;has reasons&#32;`start`\,&#32;`switch`\,&#32;`branch`\,&#32;`tree`&#32;and&#32;`shutdown`\,&#32;with&#32;`previousSessionFile`\.&#32;It is host\-dispatched\;&#32;the TUI controller has an explicit dispatcher\.&#32;The standalone custom\-tool bridge also maps additional reliability events\.&#32;For cross\-mode domain lifecycle behavior\,&#32;use and test explicit&#32;`pi.on(...)`&#32;handlers as the principal examples do\.

`shellEnv`&#32;receives&#32;`command`\,&#32;`cwd`&#32;and a copy of the shell environment\.&#32;In the supplied&#32;`BashRunner`\,&#32;the registered&#32;`bash`&#32;definition’s hook contributes environment values when&#32;`useUserShell`&#32;is enabled\.&#32;It is not an all\-subprocess environment interceptor and does not automatically affect&#32;`pi.exec()`\.

*Source\,&#32;snapshot 2026\-08\-29\:&#32;`packages/coding-agent/src/extensibility/extensions/types.ts`\,&#32;`ToolDefinition`\;&#32;`wrapper.ts`\;&#32;`runner.ts`\,&#32;`emitToolCall`\,&#32;`emitToolResult`\,&#32;`invokeNativeTool`\;&#32;`packages/coding-agent/src/sdk.ts`\,&#32;`customToolToDefinition`\;&#32;`packages/coding-agent/src/session/bash-runner.ts`\;&#32;`packages/coding-agent/src/exec/exec.ts`\;&#32;`packages/coding-agent/src/tools/approval.ts`\.*
