- 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>
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— Settrueto indicate failure.
Recommended Helpers
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:
- Check for an explicit parameter override (e.g.,
params.api_token) - Fall back to an environment variable (e.g.,
process.env.OFFICER_APIFY_TOKEN) - 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:
- Parse
versionfrom TOOL.md frontmatter (defaults to0if missing) - Compare seed version vs target version
- Only copy if seed version > target version (equal versions are skipped)
- When copying, the entire tool directory is replaced (
rm + cp)
This means:
- Bumping
versionin seed triggers an update on next restart - User edits to global tools are preserved until seed version exceeds theirs
- Tools without
versiondefault to0— 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:
- Look for
TOOL.md— parse frontmatter for metadata - Look for
index.tsorindex.js— this is the entry file - Skip if either is missing
- Register the tool with the agent (name, description, parameter schema)
- 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) |