Files
oh-my-pi/packages/coding-agent/docs/custom-tools.md
T
can1357 7d60a1af85 docs(coding-agent): updated documentation
- Updated documentation to reflect product name change from 'pi' to 'omp' throughout guides and API references.
- Restructured extension and hook documentation to clarify discovery mechanisms, loading behavior, and configuration across multiple config systems (.omp, .pi, .claude, .codex).
- Updated SDK API documentation with new method signatures: discoverHooks() -> discoverExtensions(), SessionManager methods now async, settings format changed to YAML.
- Expanded session architecture documentation with new entry types (TtsrInjectionEntry, SessionInitEntry), updated field names (fromHook -> fromExtension), and clarified session file format versioning.
- Simplified session-tree-plan.md from detailed implementation checklist to architecture summary, removing completed tasks and rollout details.
2026-02-05 01:34:20 +01:00

18 KiB

omp can create custom tools. Ask it to build one for your use case.

Custom Tools

Custom tools are additional tools that the LLM can call directly, just like the built-in read, write, edit, and bash tools. They are TypeScript modules that define callable functions with parameters, return values, and optional TUI rendering.

Key capabilities:

  • User interaction - Prompt users via pi.ui (select, confirm, input dialogs)
  • Custom rendering - Control how tool calls and results appear via renderCall/renderResult
  • TUI components - Render custom components with pi.ui.custom() (see tui.md)
  • State management - Persist state in tool result details for proper branching support
  • Streaming results - Send partial updates via onUpdate callback

Example use cases:

  • Interactive dialogs (questions with selectable options)
  • Stateful tools (todo lists, connection pools)
  • Rich output rendering (progress indicators, structured views)
  • External service integrations with confirmation flows

When to use custom tools vs. alternatives:

Need Solution
Always-needed context (conventions, commands) AGENTS.md
User triggers a specific prompt template Slash command
On-demand capability package (workflows, scripts, setup) Skill
Additional tool directly callable by the LLM Custom tool

See examples/custom-tools/ for working examples.

Quick Start

Create a file ~/.omp/agent/tools/hello/index.ts:

import type { CustomToolFactory } from "@oh-my-pi/pi-coding-agent";

const factory: CustomToolFactory = (pi) => ({
	name: "hello",
	label: "Hello",
	description: "A simple greeting tool",
	parameters: pi.typebox.Type.Object({
		name: pi.typebox.Type.String({ description: "Name to greet" }),
	}),

	async execute(toolCallId, params, onUpdate, ctx, signal) {
		const { name } = params;
		return {
			content: [{ type: "text", text: `Hello, ${name}!` }],
			details: { greeted: name },
		};
	},
});

export default factory;

The tool is automatically discovered and available in your next omp session.

Tool Locations

OMP discovers custom tools through the capability system. Native OMP tools live in a subdirectory with an index.ts entry point; .pi mirrors the same layout as a compatibility alias.

Location Scope Auto-discovered
~/.omp/agent/tools/*/index.ts User (OMP) Yes
.omp/tools/*/index.ts Project (OMP) Yes
~/.pi/agent/tools/*/index.ts User (alias) Yes
.pi/tools/*/index.ts Project (alias) Yes

Compatibility sources load flat modules (no subdirectory):

  • ~/.claude/tools/<tool>.ts (or .js, .sh, .bash, .py), .claude/tools/<tool>.*
  • ~/.codex/tools/<tool>.ts or <tool>.js, .codex/tools/<tool>.ts or <tool>.js

Tools declared by installed plugins (via ~/.omp/plugins/node_modules manifests) are also auto-discovered.

Only TypeScript/JavaScript modules are executable. .md and .json files in tools directories are treated as metadata and are not loaded as tool modules.

Example structure:

~/.omp/agent/tools/
├── hello/
│   └── index.ts        # Entry point (auto-discovered)
└── complex-tool/
    ├── index.ts        # Entry point (auto-discovered)
    ├── helpers.ts      # Helper module (not loaded directly)
    └── types.ts        # Type definitions (not loaded directly)

Name conflicts: Duplicate tool names are rejected; the first loaded tool keeps its name and later conflicts are reported as load errors.

Reserved names: Custom tools cannot use built-in tool names (read, write, edit, bash, grep, find, python, fetch, task, browser, web_search, etc.).

Available Imports

Custom tools can import from these packages:

Package Purpose Import Method
@sinclair/typebox Schema definitions (Type.Object, Type.String, etc.) Via pi.typebox.* (injected)
@oh-my-pi/pi-coding-agent Types and utilities Via pi.pi.* (injected) or direct import for types
@oh-my-pi/pi-ai AI utilities (StringEnum for Google-compatible enums) Via pi.pi.* (re-exported through coding-agent)
@oh-my-pi/pi-tui TUI components (Text, Box, etc. for custom rendering) Via pi.pi.* (re-exported through coding-agent)
@oh-my-pi/pi-utils Logging (logger) Via pi.logger (injected)

Node.js built-in modules (node:fs, node:path, etc.) are also available.

Important: Use pi.typebox.Type.* instead of importing from @sinclair/typebox directly. Dependencies are injected via the CustomToolAPI to avoid import resolution issues.

Tool Definition

import type {
	CustomTool,
	CustomToolContext,
	CustomToolFactory,
	CustomToolSessionEvent,
} from "@oh-my-pi/pi-coding-agent";

const factory: CustomToolFactory = (pi) => {
	// Destructure injected dependencies
	const { Type } = pi.typebox;
	const { StringEnum } = pi.pi;
	const { Text } = pi.pi;

	return {
		name: "my_tool",
		label: "My Tool",
		description: "What this tool does (be specific for LLM)",
		parameters: Type.Object({
			// Use StringEnum for string enums (Google API compatible)
			action: StringEnum(["list", "add", "remove"] as const),
			text: Type.Optional(Type.String()),
		}),

		async execute(toolCallId, params, onUpdate, ctx, signal) {
			// signal - AbortSignal for cancellation
			// onUpdate - Callback for streaming partial results
			// ctx - CustomToolContext with sessionManager, modelRegistry, model
			return {
				content: [{ type: "text", text: "Result for LLM" }],
				details: {
					/* structured data for rendering */
				},
			};
		},

		// Optional: Session lifecycle callback
		onSession(event, ctx) {
			if (event.reason === "shutdown") {
				// Cleanup resources (close connections, save state, etc.)
				return;
			}
			// Reconstruct state from ctx.sessionManager.getBranch()
		},

		// Optional: Custom rendering
		renderCall(args, theme) {
			/* return Component */
		},
		renderResult(result, options, theme, args) {
			/* return Component */
		},
	};
};

export default factory;

Set hidden: true to exclude a tool from the default tool list; hidden tools must be explicitly enabled by the session.

Important: Use StringEnum from pi.pi instead of Type.Union/Type.Literal for string enums. The latter doesn't work with Google's API.

CustomToolAPI Object

The factory receives a CustomToolAPI object (named pi by convention):

interface CustomToolAPI {
	cwd: string; // Current working directory
	exec(command: string, args: string[], options?: ExecOptions): Promise<ExecResult>;
	ui: ToolUIContext;
	hasUI: boolean; // false in --print or --mode rpc
	logger: typeof import("@oh-my-pi/pi-utils").logger; // File logger
	typebox: typeof import("@sinclair/typebox"); // Injected @sinclair/typebox
	pi: typeof import("@oh-my-pi/pi-coding-agent"); // Injected pi-coding-agent exports
}

interface ToolUIContext {
	select(title: string, options: string[]): Promise<string | undefined>;
	confirm(title: string, message: string): Promise<boolean>;
	input(title: string, placeholder?: string): Promise<string | undefined>;
	notify(message: string, type?: "info" | "warning" | "error"): void;
	setStatus(key: string, text: string | undefined): void;
	custom<T>(
		factory: (tui: TUI, theme: Theme, done: (result: T) => void) =>
			| (Component & { dispose?(): void })
			| Promise<Component & { dispose?(): void }>,
	): Promise<T>;
	setEditorText(text: string): void;
	getEditorText(): string;
	editor(title: string, prefill?: string): Promise<string | undefined>;
	readonly theme: Theme;
}

interface ExecOptions {
	signal?: AbortSignal; // Cancel the process
	timeout?: number; // Timeout in milliseconds
	cwd?: string; // Working directory
}

interface ExecResult {
	stdout: string;
	stderr: string;
	code: number;
	killed: boolean; // True if process was killed by signal/timeout
}

TUI and Theme are from @oh-my-pi/pi-tui (available via pi.pi).

Always check pi.hasUI before using UI methods.

Cancellation Example

Pass the signal from execute to pi.exec to support cancellation:

async execute(toolCallId, params, onUpdate, ctx, signal) {
  const result = await pi.exec("long-running-command", ["arg"], { signal });
  if (result.killed) {
    return { content: [{ type: "text", text: "Cancelled" }] };
  }
  return { content: [{ type: "text", text: result.stdout }] };
}

Error Handling

Throw an error when the tool fails. Do not return an error message as content.

async execute(toolCallId, params, onUpdate, ctx, signal) {
  const { path } = params as { path: string };

  // Throw on error - omp will catch it and report to the LLM
  if (!fs.existsSync(path)) {
    throw new Error(`File not found: ${path}`);
  }

  // Return content only on success
  return { content: [{ type: "text", text: "Success" }] };
}

Thrown errors are:

  • Reported to the LLM as tool errors (with isError: true)
  • Emitted to hooks via tool_result event (hooks can inspect event.isError)
  • Displayed in the TUI with error styling

CustomToolContext

The execute and onSession callbacks receive a CustomToolContext:

interface CustomToolContext {
	sessionManager: ReadonlySessionManager; // Read-only access to session
	modelRegistry: ModelRegistry; // For API key resolution
	model: Model | undefined; // Current model (may be undefined)
	isIdle(): boolean; // Whether agent is idle (not streaming)
	hasQueuedMessages(): boolean; // Whether user has queued messages
	abort(): void; // Abort current operation (fire-and-forget)
}

Use ctx.sessionManager.getBranch() to get entries on the current branch for state reconstruction.

Checking Queue State

Interactive tools can skip prompts when the user has already queued a message:

async execute(toolCallId, params, onUpdate, ctx, signal) {
  // If user already queued a message, skip the interactive prompt
  if (ctx.hasQueuedMessages()) {
    return {
      content: [{ type: "text", text: "Skipped - user has queued input" }],
    };
  }

  // Otherwise, prompt for input
  const answer = await pi.ui.input("What would you like to do?");
  // ...
}

Multi-line Editor

For longer text editing, use pi.ui.editor() which supports Ctrl+G for external editor:

async execute(toolCallId, params, onUpdate, ctx, signal) {
  const text = await pi.ui.editor("Edit your response:", "prefilled text");
  // Returns edited text or undefined if cancelled (Escape)
  // Ctrl+Enter to submit, Ctrl+G to open $VISUAL or $EDITOR

  if (!text) {
    return { content: [{ type: "text", text: "Cancelled" }] };
  }
  // ...
}

Session Lifecycle

Tools can implement onSession to react to session changes:

interface CustomToolSessionEvent {
	reason: "start" | "switch" | "branch" | "tree" | "shutdown";
	previousSessionFile: string | undefined;
}

Reasons:

  • start: Initial session load on startup
  • switch: User started a new session (/new) or switched to a different session (/resume)
  • branch: User branched from a previous message (/branch)
  • tree: User navigated to a different point in the session tree (/tree)
  • shutdown: Process is exiting (Ctrl+C, Ctrl+D, or SIGTERM) - use to cleanup resources

To check if a session is fresh (no messages), use ctx.sessionManager.getEntries().length === 0.

State Management Pattern

Tools that maintain state should store it in details of their results, not external files. This allows branching to work correctly, as the state is reconstructed from the session history.

interface MyToolDetails {
  items: string[];
}

const factory: CustomToolFactory = (pi) => {
  const { Type } = pi.typebox;

  // In-memory state
  let items: string[] = [];

  // Reconstruct state from session entries
  const reconstructState = (event: CustomToolSessionEvent, ctx: CustomToolContext) => {
    if (event.reason === "shutdown") return;

    items = [];
    for (const entry of ctx.sessionManager.getBranch()) {
      if (entry.type !== "message") continue;
      const msg = entry.message;
      if (msg.role !== "toolResult") continue;
      if (msg.toolName !== "my_tool") continue;

      const details = msg.details as MyToolDetails | undefined;
      if (details) {
        items = details.items;
      }
    }
  };

  return {
    name: "my_tool",
    label: "My Tool",
    description: "...",
    parameters: Type.Object({ ... }),

    onSession: reconstructState,

    async execute(toolCallId, params, onUpdate, ctx, signal) {
      // Modify items...
      items.push("new item");

      return {
        content: [{ type: "text", text: "Added item" }],
        // Store current state in details for reconstruction
        details: { items: [...items] },
      };
    },
  };
};

This pattern ensures:

  • When user branches, state is correct for that point in history
  • When user switches sessions, state matches that session
  • When user starts a new session, state resets

Custom Rendering

Custom tools can provide renderCall and renderResult methods to control how they appear in the TUI. Both are optional. See tui.md for the full component API.

How It Works

Tool output is wrapped in a Box component that handles:

  • Padding (1 character horizontal, 1 line vertical)
  • Background color based on state (pending/success/error)

Your render methods return Component instances (typically Text) that go inside this box. Use Text(content, 0, 0) since the Box handles padding.

renderCall

Renders the tool call (before/during execution):

renderCall(args, theme) {
  let text = theme.fg("toolTitle", theme.bold("my_tool "));
  text += theme.fg("muted", args.action);
  if (args.text) {
    text += " " + theme.fg("dim", `"${args.text}"`);
  }
  return new Text(text, 0, 0);
}

Called when:

  • Tool call starts (may have partial args during streaming)
  • Args are updated during streaming

renderResult

Renders the tool result:

renderResult(result, { expanded, isPartial }, theme) {
  const { details } = result;

  // Handle streaming/partial results
  if (isPartial) {
    return new Text(theme.fg("warning", "Processing..."), 0, 0);
  }

  // Handle errors
  if (details?.error) {
    return new Text(theme.fg("error", `Error: ${details.error}`), 0, 0);
  }

  // Normal result
  let text = theme.fg("success", "✓ ") + theme.fg("muted", "Done");

  // Support expanded view (Ctrl+O)
  if (expanded && details?.items) {
    for (const item of details.items) {
      text += "\n" + theme.fg("dim", `  ${item}`);
    }
  }

  return new Text(text, 0, 0);
}

Options:

  • expanded: User pressed Ctrl+O to expand
  • isPartial: Result is from onUpdate (streaming), not final
  • spinnerFrame: Spinner frame index (0-9) during partial updates

Best Practices

  1. Use Text with padding (0, 0) - The Box handles padding
  2. Use \n for multi-line content - Not multiple Text components
  3. Handle isPartial - Show progress during streaming
  4. Support expanded - Show more detail when user requests
  5. Use theme colors - For consistent appearance
  6. Keep it compact - Show summary by default, details when expanded

Theme Colors

// Foreground
theme.fg("toolTitle", text); // Tool names
theme.fg("accent", text); // Highlights
theme.fg("success", text); // Success
theme.fg("error", text); // Errors
theme.fg("warning", text); // Warnings
theme.fg("muted", text); // Secondary text
theme.fg("dim", text); // Tertiary text
theme.fg("toolOutput", text); // Output content

// Styles
theme.bold(text);
theme.italic(text);

Fallback Behavior

If renderCall or renderResult is not defined or throws an error:

  • renderCall: Shows tool name
  • renderResult: Shows raw text output from content

Execute Function

async execute(toolCallId, args, onUpdate, ctx, signal) {
  // Type assertion for params (TypeBox schema doesn't flow through)
  const params = args as { action: "list" | "add"; text?: string };

  // Check for abort
  if (signal?.aborted) {
    return { content: [...], details: { status: "aborted" } };
  }

  // Stream progress
  onUpdate?.({
    content: [{ type: "text", text: "Working..." }],
    details: { progress: 50 },
  });

  // Return final result
  return {
    content: [{ type: "text", text: "Done" }],  // Sent to LLM
    details: { data: result },  // For rendering only
  };
}

Multiple Tools from One File

Return an array to share state between related tools:

const factory: CustomToolFactory = (pi) => {
  // Shared state
  let connection = null;

  const handleSession = (event: CustomToolSessionEvent, ctx: CustomToolContext) => {
    if (event.reason === "shutdown") {
      connection?.close();
    }
  };

  return [
    { name: "db_connect", onSession: handleSession, ... },
    { name: "db_query", onSession: handleSession, ... },
    { name: "db_close", onSession: handleSession, ... },
  ];
};

Examples

See examples/custom-tools/todo/index.ts for a complete example with:

  • onSession for state reconstruction
  • Custom renderCall and renderResult
  • Proper branching support via details storage

Test by copying the example into your tools directory and restarting omp:

cp -r packages/coding-agent/examples/custom-tools/todo ~/.omp/agent/tools/