Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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 + Zod parameter schema).
  • Extension: lifecycle/event framework that can register tools and intercept/modify events.
  • Hook: TypeScript module that registers handlers with pi.on(...) (same event bus as extensions; --hook aliases --extension).
  • 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:

  1. SDK-provided custom tools (options.customTools)

    • Wrapped into agent tools via CustomToolAdapter or extension wrappers.
    • Always included in the initial active tool set in SDK bootstrap.
  2. 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.
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/details

Discovery locations (loader API)

discoverAndLoadCustomTools(configuredPaths, cwd, builtInToolNames) merges:

  1. Capability providers (toolCapability), all user-level (a working tree never contributes tools):
    • Native Veyyon config (~/.veyyon/profiles/<name>/agent/tools)
    • Claude config (~/.claude/tools)
    • Codex config (~/.codex/tools)
    • Claude marketplace plugin cache provider
  2. Installed plugin manifests (~/.veyyon/profiles/<profile>/plugins/node_modules/* via plugin loader; a project root <anchor>/.veyyon/plugins is enumerated the same way)
  3. 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.
  • .md and .json files 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 "@veyyon/coding-agent";

const factory: CustomToolFactory = (pi) => ({
  name: "repo_stats",
  label: "Repo Stats",
  description: "Counts tracked TypeScript files",
  parameters: pi.zod.object({
    glob: pi.zod.string().optional().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;

Schemas are authored with Zod (pi.zod) and flow through the shared validation/wire pipeline.

Factory return type:

  • CustomTool
  • CustomTool[]
  • Promise<CustomTool | CustomTool[]>

API surface passed to factories (CustomToolAPI)

From types.ts and loader.ts:

  • cwd: host working directory
  • exec(command, args, options?): process execution helper
  • ui: UI context (can be no-op in headless modes)
  • hasUI: false in non-interactive flows
  • logger: shared file logger
  • typebox: self-contained compatibility shim for legacy TypeBox-style schemas (legacy/compat, prefer arktype or zod for new tools)
  • zod: injected zod/v4 module (canonical for new schemas)
  • pi: injected @veyyon/coding-agent exports
  • pushPendingAction(action): register a preview action for hidden resolve tool (docs/internal/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);
  • params is statically typed from your Zod/TypeBox schema via Static<TParams>.
  • Runtime argument validation happens before execution in the agent loop.
  • onUpdate emits partial results for UI streaming.
  • ctx includes sessionManager, modelRegistry, current model, isIdle(), hasQueuedMessages(), abort(), and optional settings, fetch, and autoApprove.
  • signal carries cancellation.

An extension tool registered with pi.registerTool takes the same five arguments in a different order, with the signal third:

execute(toolCallId, params, signal, onUpdate, ctx);

Use the order that belongs to the API you are writing against. Copying one into the other place raises no error at the call site and none at runtime either: the arguments still arrive, so ctx is the update callback and the first ctx.sessionManager you touch is undefined.

CustomToolAdapter bridges this to the agent tool interface and forwards calls in the correct argument order.

Tool definitions may also declare strict, hidden, deferrable, mcpServerName, mcpToolName, approval, and formatApprovalDetails.

How tools are exposed to the model

  • Tools are wrapped into AgentTool instances (CustomToolAdapter or 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 --tools currently 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, options, theme)
  • renderResult(result, options, theme, args?)

Runtime behavior in TUI:

  • If hooks exist, tool output is rendered inside a Box container.
  • renderResult receives { expanded, isPartial, spinnerFrame? }.

If a rendering hook throws, veyyon catches it so the session keeps running, and then reports it where your card would have been:

✗ tool "widget" result renderer threw: payload has no rows — showing raw output; fix or remove the renderer

The notice states which hook failed, because renderCall and renderResult fail independently, and it shows what you are looking at instead: the tool label alone for a failed renderCall, the raw text output for a failed renderResult. When there is no raw output to fall back to, it reports that too rather than implying output you cannot see. The failure is also written to the log.

Returning undefined from a hook is different: that is how you decline to draw for a particular call, and it renders the default with no notice.

Session/state handling

Optional onSession(event, ctx) receives session lifecycle events, including:

  • start, switch, branch, tree, shutdown
  • auto_compaction_start, auto_compaction_end
  • auto_retry_start, auto_retry_end
  • ttsr_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 execute is treated as tool failure.
  • Agent runtime converts failures into tool result messages with isError: true and error text content.
  • With extension wrappers, tool_result handlers can further rewrite content/details and even override error status.

Cancellation

  • Agent abort propagates through AbortSignal to execute.
  • Forward signal to subprocess work (pi.exec(..., { signal })) for cooperative cancellation.
  • ctx.abort() lets a tool request abort of the current agent operation.

onSession errors

  • onSession errors 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 details for renderer/state reconstruction.
  • Guard UI usage with pi.hasUI.
  • Treat .md/.json in tool directories as metadata, not executable modules.