# 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:** ```yaml --- 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:** ```typescript type ToolResult = { content: Array<{ type: string; text: string }>; isError?: boolean; }; type Params = { name: string; }; export async function execute( _toolCallId: string, params: Params, ): Promise { 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.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//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//tools//` 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 ```yaml --- 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: ```typescript 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, signal?: AbortSignal, onUpdate?: OnUpdate, ): Promise { // 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: ```typescript // 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. ### Recommended Helpers Define `ok()` and `err()` helpers to keep return statements clean: ```typescript 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`: ```typescript type Params = { query: string; max_results?: number; format?: string; }; export async function execute( _toolCallId: string, params: Params, _signal?: AbortSignal, onUpdate?: OnUpdate, ): Promise { 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: ```typescript 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: ```typescript export async function execute(_toolCallId: string, params: Params): Promise { 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: ```typescript 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: ```typescript 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: ```typescript 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: ```typescript // 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: ```typescript 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: ```typescript // ❌ 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 { 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:** ```yaml --- 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:** ```typescript 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[] { 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 { 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) |