Files
platform/seed/tools/TOOLS.md
T
pastilhasandClaude Opus 4.6 0068244356 claude-code as channel model, container trust/permissions fixes, terminal cwd fix
- add claude-code as virtual model in channel messaging (telegram/discord/whatsapp)
- new send-claude-code.ts: docker exec claude -p with session resumption
- route claude-code model in sendAndAwait before Pi pipeline
- append claude-code to listPiModels output
- fix container .claude mount (rw for sub-mounts), hooks format (matcher-based)
- pre-seed hasTrustDialogAccepted and bypassPermissions in container settings
- git init in entrypoint to skip workspace trust prompt
- fix ~/~ double-tilde in CommandTerminalWrapper cwd resolution
- remove --continue from claude-code panel command

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-02 20:00:45 +00:00

18 KiB

Tools

A tool is a callable capability that agents can use during task execution. Each tool lives in its own directory and is defined by a TOOL.md file and an index.ts (or index.js) entry point.

Tools are loaded by the tool-loader extension at startup. They appear in the agent's system prompt and can be invoked by name.

Quick Start — Minimal Tool

Create a directory with two files:

tools/
  hello/
    TOOL.md
    index.ts

TOOL.md:

---
name: hello
label: Hello
description: Says hello to the user. Use this when the user wants a greeting.
version: 1
language: typescript
inputs:
  name:
    type: string
    description: Who to greet.
---

# Hello Tool

Greets the user by name.

## Usage

Call with a `name` parameter to get a personalized greeting.

index.ts:

type ToolResult = {
  content: Array<{ type: string; text: string }>;
  isError?: boolean;
};

type Params = {
  name: string;
};

export async function execute(
  _toolCallId: string,
  params: Params,
): Promise<ToolResult> {
  return { content: [{ type: 'text', text: `Hello, ${params.name}!` }] };
}

That's it. The tool-loader finds it, registers it, and the agent can call it.

File Structure

tools/
  <tool-name>/
    TOOL.md       # Metadata + documentation (required)
    index.ts      # Implementation (required)
    bin/          # Optional helper scripts

Both TOOL.md and an entry file (index.ts or index.js) are required. The loader skips directories missing either.

Additional files (scripts, configs, READMEs) are allowed but not loaded — only TOOL.md and the entry file matter.

Tool Locations

Tools live in two directories:

Location Purpose Managed by
DATA_PATH/tools/ Global tools (synced from seed) sync-tools.ts at startup
DATA_PATH/<email>/tools/ User-created tools Manual (user creates them)

Both are mounted into containers and discovered via the PI_TOOLS_DIRS environment variable. If a user tool has the same name as a global tool, the user tool overrides it (last-writer-wins).

To create a user tool, make a new directory in DATA_PATH/<email>/tools/<tool-name>/ with TOOL.md and index.ts. It will be available after the next agent session starts.

TOOL.md Format

Two parts: frontmatter (YAML metadata) and body (Markdown documentation).

Frontmatter

---
name: tool_name
label: Tool Name
description: What the tool does and when the agent should use it.
version: 1
language: typescript
inputs:
  param_name:
    type: string
    description: What this parameter is for.
  optional_param:
    type: number
    description: An optional parameter.
    optional: true
  secret_param:
    type: string
    description: A sensitive value (e.g., API token).
    optional: true
    sensitive: true
  mode:
    type: enum
    values: single,batch
    description: Choose between modes.
---

Fields

Field Type Required Description
name string yes Identifier for the tool (used in tool calls). Use snake_case.
label string no Human-readable display name. Defaults to name if omitted.
description string yes What the tool does. This appears in the agent's system prompt — make it specific enough for the agent to know when to use it.
version integer yes Version number. Used by sync-tools to detect updates. Always include and bump when changing the tool. If omitted, defaults to 0, causing unpredictable sync behavior.
language string no typescript, bash, or python. Defaults to typescript.
inputs object no Input parameters the tool accepts. Keys are parameter names.

Input Types

Type Schema Notes
string Type.String() Default if type is unrecognized
number Type.Number()
boolean Type.Boolean()
enum Type.Union(literals) Requires values field (comma-separated or array)

Note: object and array types are not supported by the schema builder. If you need complex inputs, accept a JSON string and parse it in the execute function.

Input Properties

Field Type Required Description
type string yes string, number, boolean, or enum.
description string yes What this parameter is for. Shown to the agent.
optional boolean no Whether the parameter is optional. Defaults to required.
sensitive boolean no Mark sensitive values (tokens, passwords). Prevents logging in UI.
values string no Comma-separated allowed values for enum type.
default any no Default value if not provided.

Body

The body is Markdown that the agent sees when the tool is loaded. This is your main documentation — the agent reads it to understand how to use the tool.

Include:

  • Title# Tool Name
  • Usage — How to call the tool, what parameters to pass, and what to expect back.
  • Examples — Common usage patterns with example parameter values.
  • Authentication — How credentials are resolved (env vars, integrations, etc.) if applicable.
  • Error Handling — What errors can occur and what they mean.
  • Notes — Limits, external dependencies, related links.

Write the body as instructions for the agent. The agent decides when and how to call the tool based on this documentation.

index.ts Format

The entry file must export an execute function:

type ToolResult = {
  content: Array<{ type: string; text: string }>;
  isError?: boolean;
};

type OnUpdate = (partial: { content: Array<{ type: string; text: string }> }) => void;

export async function execute(
  toolCallId: string,
  params: Record<string, unknown>,
  signal?: AbortSignal,
  onUpdate?: OnUpdate,
): Promise<ToolResult> {
  // Implementation here
}

Parameters

Parameter Description
toolCallId Unique ID for this tool call. Usually unused — prefix with _.
params Input values from the agent, matching inputs in TOOL.md.
signal AbortSignal for cancellation. Not widely used yet — accept and ignore.
onUpdate Callback for streaming progress to the agent during long operations.

Return Value

Return a ToolResult object:

// Success
return { content: [{ type: 'text', text: 'Done! Created 5 files.' }] };

// Error — agent sees the error and can react
return { content: [{ type: 'text', text: 'API key not found.' }], isError: true };
  • content — Array of content blocks. Usually one { type: 'text', text: '...' }.
  • isError — Set true to indicate failure.

Define ok() and err() helpers to keep return statements clean:

function ok(text: string): ToolResult {
  return { content: [{ type: 'text', text }] };
}

function err(text: string): ToolResult {
  return { content: [{ type: 'text', text }], isError: true };
}

Typed Parameters

Define a Params type matching your TOOL.md inputs instead of using Record<string, unknown>:

type Params = {
  query: string;
  max_results?: number;
  format?: string;
};

export async function execute(
  _toolCallId: string,
  params: Params,
  _signal?: AbortSignal,
  onUpdate?: OnUpdate,
): Promise<ToolResult> {
  const { query, max_results = 10 } = params;
  // ...
}

Progress Updates

Use onUpdate to stream status during long-running operations. The agent sees each update in real time:

onUpdate?.({ content: [{ type: 'text', text: 'Phase 1: Downloading data...' }] });
// ... do work ...
onUpdate?.({ content: [{ type: 'text', text: 'Phase 2: Processing 500 files...' }] });
// ... do work ...
return ok('Done! Processed 500 files.');

Error Handling

Wrap the entire execute body in try/catch. Return errors as ToolResult with isError: true — never throw from execute:

export async function execute(_toolCallId: string, params: Params): Promise<ToolResult> {
  try {
    // ... implementation ...
    return ok('Success');
  } catch (e) {
    return err(`Failed: ${e instanceof Error ? e.message : String(e)}`);
  }
}

For missing configuration, return a helpful error that tells the agent what to do:

const token = params.api_token ?? process.env.OFFICER_APIFY_TOKEN;
if (!token) {
  return err('Apify API token not configured. Ask the user to add it in Settings → Integrations.');
}

Authentication

Tools should resolve credentials internally. Pattern:

  1. Check for an explicit parameter override (e.g., params.api_token)
  2. Fall back to an environment variable (e.g., process.env.OFFICER_APIFY_TOKEN)
  3. Return a helpful error if neither is available

Environment variables are set by pi-bridge.ts from the integration config in the database (Settings → Integrations).

Large Output

If a tool may return large data, provide an output_path parameter. When set, save data to the file and return a summary:

if (params.output_path) {
  writeFileSync(params.output_path, JSON.stringify(items, null, 2));
  return ok(`${items.length} items saved to ${params.output_path}`);
}

This prevents flooding the agent's context window.

Runtime Environment

Tools run inside sandboxed Docker containers using Node.js (not Bun).

APIs

Use Node.js standard library only:

Need Use Don't use
File I/O fs.readFileSync, fs.writeFileSync Bun.file, Bun.write
Delays setTimeout, setInterval Bun.sleep
HTTP fetch (Node 18+) Bun-specific fetch options
Child processes child_process.execFileSync, spawn Bun.spawn
Paths path.join, path.dirname

Available Environment Variables

These are set by pi-bridge.ts and available in all tool containers:

Variable Description
HOME Container home directory
OFFICER_USER_HOME Same as HOME
OFFICER_USER_ROOT User data root (/officer/user)
PI_TOOLS_DIRS Tool discovery paths (colon-separated)
OFFICER_EMAIL_DB Path to email SQLite database
OFFICER_RESOURCES JSON object with all configured resources/integrations
PI_SEARXNG_URL SearXNG search engine URL
OFFICER_APIFY_TOKEN Apify API token (if configured)
OFFICER_BROWSER_RELAY_PORT Browser relay port (if configured)
OFFICER_BROWSER_RELAY_TOKEN Browser relay auth token (if configured)

OFFICER_RESOURCES is a JSON string containing all resource configs from Settings → Integrations:

const resources = JSON.parse(process.env.OFFICER_RESOURCES ?? '{}');
const ocrConfig = resources['optical-character-recognition'];

Container Filesystem

Mount Path in container Access
Global tools /officer/tools/ Read-only
User tools /officer/user/tools/ Read-only
User data /officer/user/ Read-write
Email database /officer/data/emails.db Read-write

Referencing Local Files

If your tool includes helper scripts (e.g., Python/bash in a bin/ directory), resolve them relative to the entry file:

// Works in both ESM and CJS contexts
const TOOL_DIR = typeof __dirname !== 'undefined'
  ? __dirname
  : dirname(fileURLToPath(import.meta.url));
const BIN_DIR = join(TOOL_DIR, 'bin');

// Then call scripts:
execFileSync('python3', [join(BIN_DIR, 'process.py'), inputPath]);

External Dependencies

If your tool requires system binaries (e.g., python3, tesseract, ffmpeg), check for them early and return a helpful error:

import { execSync } from 'node:child_process';

function hasCommand(cmd: string): boolean {
  try {
    execSync(`which ${cmd}`, { stdio: 'ignore' });
    return true;
  } catch {
    return false;
  }
}

// In execute():
if (!hasCommand('python3')) {
  return err('python3 is required but not installed in the container.');
}

Sync and Discovery

How Sync Works

At server startup, sync-tools.ts copies tools from seed/tools/ to DATA_PATH/tools/ (the global tools directory). The sync is version-based:

  1. Parse version from TOOL.md frontmatter (defaults to 0 if missing)
  2. Compare seed version vs target version
  3. Only copy if seed version > target version (equal versions are skipped)
  4. When copying, the entire tool directory is replaced (rm + cp)

This means:

  • Bumping version in seed triggers an update on next restart
  • User edits to global tools are preserved until seed version exceeds theirs
  • Tools without version default to 0 — always include a version number

How Discovery Works

The tool-loader extension reads PI_TOOLS_DIRS (colon-separated paths) and scans each directory for tool subdirectories. For each subdirectory:

  1. Look for TOOL.md — parse frontmatter for metadata
  2. Look for index.ts or index.js — this is the entry file
  3. Skip if either is missing
  4. Register the tool with the agent (name, description, parameter schema)
  5. Lazy load the entry file on first call (not at startup)

If multiple tools share the same name, the last one registered wins. Since user tools are loaded after global tools, user tools override global tools with the same name.

Common Mistakes

No execute export

The entry file must export execute as a named export. Class-based patterns, CLI entry points (process.argv), and module.exports do not work:

// ❌ Wrong — class pattern
export class MyTool { async run() { ... } }

// ❌ Wrong — CLI entry point
if (require.main === module) { main(); }

// ❌ Wrong — console.log instead of return
export async function execute(_id: string, params: Params) {
  console.log('result');  // Agent never sees this
}

// ✅ Correct
export async function execute(_id: string, params: Params): Promise<ToolResult> {
  return { content: [{ type: 'text', text: 'result' }] };
}

Forgetting to bump version

After editing a seed tool's code or TOOL.md, bump version in the frontmatter. Otherwise sync-tools won't copy the update to the global directory and the agent will keep using the old version.

Using Bun APIs

Tools run in Node.js containers. Bun.file(), Bun.write(), Bun.sleep() will throw ReferenceError.

Throwing instead of returning errors

Never throw from execute. Always catch and return { isError: true }. Unhandled throws produce generic error messages the agent can't act on.

Unnecessary files

Tools don't need package.json, tsconfig.json, node_modules, or test directories. The tool-loader only reads TOOL.md and index.ts. Extra files are harmless but add clutter.

Full Example — Database Query Tool

A complete tool that queries a SQLite database:

TOOL.md:

---
name: my_db
label: My Database
description: Query the application database. Use this to look up records, run aggregations, and search data.
version: 1
language: typescript
inputs:
  action:
    type: enum
    values: query,stats
    description: "Action to perform: query runs SQL, stats shows database overview."
  sql:
    type: string
    description: SQL SELECT statement to execute (query action only).
    optional: true
  limit:
    type: number
    description: Maximum number of results to return.
    optional: true
---

# My Database Tool

Query the application database using SQL.

## Usage

Use `action=stats` for a database overview. Use `action=query` with a `sql` parameter for specific queries.

## Examples

Get stats:
- action: stats

Search records:
- action: query, sql: "SELECT * FROM users WHERE name LIKE '%john%' LIMIT 10"

index.ts:

import { execFileSync } from 'node:child_process';
import { existsSync } from 'node:fs';

type ToolResult = {
  content: Array<{ type: string; text: string }>;
  isError?: boolean;
};

type Params = {
  action: string;
  sql?: string;
  limit?: number;
};

const DB_PATH = process.env.MY_DB_PATH ?? '/officer/data/my.db';

function ok(text: string): ToolResult {
  return { content: [{ type: 'text', text }] };
}

function err(text: string): ToolResult {
  return { content: [{ type: 'text', text }], isError: true };
}

function queryJson(sql: string): Record<string, unknown>[] {
  const output = execFileSync('sqlite3', ['-json', DB_PATH], {
    input: sql,
    encoding: 'utf-8',
    timeout: 10000,
  });
  const trimmed = output.trim();
  if (!trimmed) return [];
  return JSON.parse(trimmed);
}

export async function execute(_toolCallId: string, params: Params): Promise<ToolResult> {
  if (!existsSync(DB_PATH)) {
    return err(`Database not found at ${DB_PATH}.`);
  }

  try {
    switch (params.action) {
      case 'query': {
        if (!params.sql) return err('sql parameter is required for query action.');
        if (!params.sql.trim().toLowerCase().startsWith('select')) {
          return err('Only SELECT statements are allowed.');
        }
        const limit = params.limit ? ` LIMIT ${params.limit}` : '';
        const rows = queryJson(`${params.sql}${limit}`);
        if (rows.length === 0) return ok('No results.');
        const text = rows.map((r, i) => {
          const fields = Object.entries(r).map(([k, v]) => `${k}: ${v ?? ''}`).join(' | ');
          return `${i + 1}. ${fields}`;
        }).join('\n');
        return ok(`${rows.length} results:\n\n${text}`);
      }

      case 'stats': {
        const tables = queryJson("SELECT name FROM sqlite_master WHERE type='table'");
        const lines = tables.map((t) => {
          const count = queryJson(`SELECT COUNT(*) as c FROM "${t.name}"`);
          return `${t.name}: ${(count[0]?.c as number) ?? 0} rows`;
        });
        return ok(`Tables:\n${lines.join('\n')}`);
      }

      default:
        return err(`Unknown action: "${params.action}". Available: query, stats.`);
    }
  } catch (e) {
    return err(`Database error: ${e instanceof Error ? e.message : String(e)}`);
  }
}

Existing Tools

Tool Description
gmail Read Gmail messages, threads, labels via Google API
web_search Search the web via SearXNG
web_fetch Fetch and extract content from URLs
browser Control a Chrome browser via Browser Relay
apify Run any Apify actor (web scraping, social media data)
convert_audio_to_mp3 Convert audio files to MP3 via ffmpeg
ffmpeg Run ffmpeg/ffprobe commands for any audio/video processing
ocr Optical character recognition on images
email_db Query the synced email database
pdf_categorizer Categorize and organize PDF files (user tool)