- 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>
604 lines
18 KiB
Markdown
604 lines
18 KiB
Markdown
# 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<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
|
|
|
|
```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<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:
|
|
|
|
```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<string, unknown>`:
|
|
|
|
```typescript
|
|
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:
|
|
|
|
```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<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:
|
|
|
|
```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<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:**
|
|
|
|
```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<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) |
|