# Built-in Tools

VeilCLI ships 24 built-in tools. Agents access tools through the permission system — see [Permissions](07-permissions.md) for how to allow/deny them.

---

## Tool Discovery — Four-Tier Loading

VeilCLI uses a four-tier model to keep context lean while giving agents access to unlimited custom tools:

| Tier | What | How the LLM sees it | Purpose |
|------|------|---------------------|---------|
| 1 | **Built-in tools** | Full schema in `tools[]` on every call | Always callable |
| 2 | **Custom tools (all)** | Names + one-line descriptions in system prompt | Awareness only |
| 3 | **`tool_search`** | Full JSON schemas returned as text | Inspect and decide |
| 4 | **`tool_activate`** | Tool schema added to `tools[]` for the next LLM call | Explicit opt-in to call |

**Workflow for using a custom tool:**

```
tool_search("query")         → read full schemas, inspect parameters
tool_activate("chosen_tool") → tool becomes callable in the next iteration
chosen_tool(args)            → executes normally
```

`tool_search` is **read-only** — searching does not make any tool callable. The LLM must explicitly call `tool_activate` once it has decided to use a specific custom tool. This prevents speculative searches from polluting the active tool list.

Both `tool_search` and `tool_activate` are pre-loaded as built-ins (Tier 1), so they are always available without any prior activation step.

---

## Tool Summary

| Tool | Category | Description |
|------|----------|-------------|
| [`bash`](#bash) | Shell | Execute shell commands (persistent shell per session, optional background mode) |
| [`bash_output`](#bash_output) | Shell | Poll a running background shell for new output |
| [`kill_shell`](#kill_shell) | Shell | Terminate a running background shell |
| [`read_file`](#read_file) | File I/O | Read file contents |
| [`write_file`](#write_file) | File I/O | Write/create files |
| [`edit_file`](#edit_file) | File I/O | Patch files with find-and-replace |
| [`list_dir`](#list_dir) | File I/O | List directory contents |
| [`glob`](#glob) | File I/O | Find files by pattern |
| [`grep`](#grep) | File I/O | Search file contents by regex |
| [`web_search`](#web_search) | Web | DuckDuckGo search |
| [`web_fetch`](#web_fetch) | Web | Fetch a URL |
| [`memory_read`](#memory_read) | Memory | Read agent or project memory |
| [`memory_write`](#memory_write) | Memory | Append to agent or project memory |
| [`memory_search`](#memory_search) | Memory | Search memory for relevant content |
| [`todo_write`](#todo_write) | Productivity | Write a todo list |
| [`todo_read`](#todo_read) | Productivity | Read the current todo list |
| [`agent_spawn`](#agent_spawn) | Multi-agent | Start a new chat session with an agent, get sessionId back |
| [`agent_message`](#agent_message) | Multi-agent | Send a message to a specific agent session (sync by default; pass `async_inform: true` for fire-and-forget) |
| [`agent_control`](#agent_control) | Multi-agent | Inspect (get-state / get-summary / get-last-message), update (reasoning / budget), or stop a subagent — auto-paired with `agent_spawn` |
| [`log_write`](#log_write) | Utility | Write a progress log entry |
| [`sleep`](#sleep) | Utility | Wait N seconds |
| [`schedule_wakeup`](#schedule_wakeup) | Utility | Schedule a self wake-up — inject a message as a new turn after a delay (one-shot or repeating) |
| [`tool_search`](#tool_search) | Utility | Search available tools by name or description |
| [`tool_activate`](#tool_activate) | Utility | Make a custom tool callable (load into active tool list) |

---

## File I/O Tools

### `bash`

Execute a shell command. Each chat session has its own **persistent interactive shell** — `cd`, `export`, and aliases survive across calls. stdout/stderr stream live to the SSE channel as `tool.chunk` events; the final return is the combined output string. For long-running processes, pass `run_in_background: true` and poll/terminate via [`bash_output`](#bash_output) / [`kill_shell`](#kill_shell).

| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| `command` | string | ✓ | Shell command to execute |
| `timeout` | integer | | Timeout in seconds. Default **120**, max **600**. Ignored when `run_in_background` is true. |
| `run_in_background` | boolean | | If true, spawn the command in the background and return a `shell_id` immediately. Default false. |
| `workingDir` | string | | Initial working directory. **Honored only on the FIRST `bash` call in a session** (when the persistent shell is first spawned). Use `cd` for subsequent changes. |

**Foreground return:** stdout + stderr interleaved in order produced. Non-zero exit codes prefix with `exit code: N`. Output is capped at **30000 chars** with a truncation footer if exceeded.

**Background return:** JSON string `{ shell_id, pid, status: 'running', startedAt, command, note }`. Pass `shell_id` to `bash_output` / `kill_shell`.

**Shell lifecycle:**
- The persistent foreground shell is spawned on the first `bash` call in a session.
- `~/.bashrc` is sourced once at spawn for normal-terminal ergonomics (aliases, env).
- If a command wedges (unterminated heredoc, trailing backslash) the shell is SIGKILL'd and respawned on timeout or byte-cap; the session loses its `cd`/`export` state as a side effect.
- All shells owned by a session are killed when the session closes (normal exit, cancel, error, DELETE).

**Known limitations:** interactive prompts (`sudo`, `git commit` without `-m`) are not supported — stdin is a pipe, not a TTY. Binary output (`cat some.png`) is UTF-8 decoded and will be mangled — redirect to a file and use `read_file` instead.

```
"Run: cd /tmp && pwd"         → "/tmp"
"Run: pwd"                    → "/tmp"   (cd persisted)
"Run: export X=1 && echo $X"  → "1"
"Run: echo $X"                → "1"      (env persisted)

"Run: npm run build" with run_in_background:true
→ { "shell_id": "bg_a1b2c3d4e5", "pid": 12345, "status": "running", ... }
```

---

### `bash_output`

Poll a running background shell for new output since the last read. Cursor-based: each call returns only bytes that arrived after the previous `bash_output`. Non-blocking — returns whatever is buffered at call time.

| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| `shell_id` | string | ✓ | The `shell_id` returned from a prior `bash` call with `run_in_background: true`. |

**Returns:** status (`running` / `exited` / `killed`), exit code when terminated, totals, and any new stdout/stderr since last poll. Once the shell exits, further polls return the same final state.

---

### `kill_shell`

Terminate a running background shell. Sends `SIGTERM`, escalates to `SIGKILL` after 2 seconds if the process has not exited. Returns the shell's final state (status, exitCode, signal, byte totals).

| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| `shell_id` | string | ✓ | The `shell_id` of the background shell to terminate. |

---

### `read_file`

Read the contents of a file, with optional line range. Supports **multimodal output** for images and audio — the LLM receives the actual file content as a native content block.

| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| `file` | string | ✓ | Absolute path to the file |
| `offset` | integer | | 1-indexed line number to start from (text files only) |
| `limit` | integer | | Number of lines to read (text files only) |

**Default line limit:** When no `offset` or `limit` is specified, text files longer than 2000 lines are truncated to the first 2000 lines. A footer is appended: `(File has N total lines. Showing first 2000. Use offset/limit to read more.)` When `offset` or `limit` is explicitly provided, the limit does not apply.

**Empty files:** Files with zero bytes or whitespace-only content return `<system-reminder>The file {path} exists but has empty contents.</system-reminder>`.

**Side effect — registers edit safety gates.** Every successful `read_file` call records two things against the current session: (a) a DB-backed "this file was read" marker that satisfies `write_file`/`edit_file`'s must-have-read gate, and (b) an in-memory mtime snapshot used by their concurrent-edit detection. If the server restarts, the DB marker survives but the mtime snapshot does not — so a post-restart edit requires re-reading the file.

**Behavior by file type:**

| File type | Extensions | LLM receives | DB stores |
|-----------|------------|-------------|-----------|
| Text | `.js`, `.md`, `.json`, etc. | Line-numbered text (string) | Same string |
| Image | `.png`, `.jpg`, `.jpeg`, `.gif`, `.webp`, `.bmp`, `.svg` | `image_url` content block (base64), if model supports image input; text fallback otherwise | `"Image file: photo.jpg (45 KB)"` |
| Audio | `.wav`, `.mp3`, `.aac`, `.ogg`, `.flac`, `.m4a` | `input_audio` content block (base64), if model supports audio input; text fallback otherwise | `"Audio file: clip.wav (1.2 MB)"` |
| Other binary | `.pdf`, `.zip`, `.wasm`, etc. | `"Binary file: x.pdf (1.2 MB). Cannot display as text."` | Same string |

Image and audio files larger than **20 MB** return a text-only size description (no base64).

**Model modality gating:** Before returning multimodal content, the tool checks the current model's `input_modalities` (from `models.json`). If the model does not support `image` or `audio`, a text fallback is returned instead (e.g. `"Image file: photo.jpg (45 KB). Current model does not support image input."`).

Multimodal content is assembled in-memory for the current LLM turn only — it is **not stored** in the database. On session replay (e.g. resuming a conversation), the LLM sees the text description instead of the original image/audio.

```
"Read: /home/user/workspace/package.json"
→ "   1 | {\n   2 |   \"name\": \"veilcli\"..."

"Read: /home/user/workspace/screenshot.png"
→ LLM sees the image; DB stores "Image file: screenshot.png (128.5 KB)"

"Read: /home/user/workspace/data.bin"
→ "Binary file: data.bin (4.7 MB). Cannot display as text."
```

---

### `write_file`

Create or overwrite a file.

| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| `file` | string | ✓ | Absolute path to the file |
| `content` | string | ✓ | Content to write |

Creates parent directories if they don't exist.

**Safety gates for existing files:**

1. **Must-have-read.** Requires a prior `read_file` on the same path in the current session (post-compaction). Returns an error otherwise. New file creation skips this check.
2. **Concurrent-edit detection.** The file's mtime is recorded at read time and compared at write time. If the on-disk mtime differs (> 10 ms fuzz) from what was recorded, the write is rejected with a "File changed on disk since you last read it" error — re-read to pick up the external changes.
3. **Post-restart re-read.** The mtime tracker is in-memory; after a server restart, even though the DB-backed read-gate passes, the mtime record is gone. In that case, re-read the file before writing.

**Atomic write.** The content is written to a temp file in the same directory and then `rename`d into place. A crash mid-write (power loss, SIGKILL) either leaves the destination untouched (old content preserved) or fully overwritten (new content). Never partial.

**Symlinks.** Writes pass *through* symbolic links — the target is updated and the link preserved. Broken symlinks are refused with a `BROKEN_SYMLINK` error rather than silently replaced with a regular file.

---

### `edit_file`

Patch a file by replacing an exact string with a new string. Safer than `write_file` for targeted edits.

| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| `file` | string | ✓ | Absolute path to the file |
| `old_string` | string | ✓ | Exact text to replace (must be unique unless `replace_all` is true) |
| `new_string` | string | ✓ | Replacement text |
| `replace_all` | boolean | | If true, replaces all occurrences of `old_string`. Default false. |

**Indentation matters.** `old_string` must match the file content **EXACTLY** — including every space, tab, and newline. For multi-line edits the indentation (tabs vs spaces) must match character-for-character; a mismatched leading space will cause `"old_string not found"` even when the snippet looks identical. Read the file first and copy the exact whitespace from the read output.

**Safety gates.** Same as `write_file`:
- Must-have-read + concurrent-edit mtime check + post-restart re-read.
- Atomic write (temp + rename).
- Writes through symlinks; refuses broken symlinks.

Returns an error if `old_string` is not found. If `old_string` appears more than once, returns an error unless `replace_all` is set to true.

---

### `list_dir`

List files and directories in a path.

| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| `dir` | string | ✓ | Directory path |
| `recursive` | boolean | | Include subdirectories (default: false) |

Returns a formatted list with file sizes and types.

---

### `glob`

Find files matching a glob pattern.

| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| `pattern` | string | ✓ | Glob pattern (e.g. `**/*.js`, `src/*.ts`) |
| `cwd` | string | | Base directory (default: workspace root) |
| `limit` | integer | | Max results (default: 50) |

---

### `grep`

Search file contents with a regex or literal string.

| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| `pattern` | string | ✓ | Search pattern (regex or literal) |
| `path` | string | ✓ | File or directory to search |
| `recursive` | boolean | | Search subdirectories (default: false) |
| `case_sensitive` | boolean | | Case-sensitive match (default: false) |
| `fixed_strings` | boolean | | Treat pattern as literal string (default: false) |
| `include` | string | | Glob to filter files (e.g. `*.js`) |
| `context_lines` | integer | | Lines of context around each match |

Returns matching lines with file path and line number.

---

## Web Tools

### `web_search`

Search the web using DuckDuckGo. No API key required.

| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| `query` | string | ✓ | Search query |
| `numResults` | integer | | Number of results (default: 5, max: 10) |

Returns a list of results with titles, URLs, and snippets.

---

### `web_fetch`

Fetch the content of a URL.

| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| `url` | string | ✓ | URL to fetch |
| `mode` | string | | `"text"` (default, HTML tags stripped), `"json"` (parsed + pretty-printed), or `"base64"` |
| `page` | integer | | 1-indexed page (default 1). Text-mode responses are paginated at **50000 chars per page**. Only honored in `text` mode. |

Returns the response text. Multi-page responses include a header `[Page N of M — total X chars]` and a footer indicating whether more pages are available (`[More content available — call web_fetch again with page:N+1 …]`) or this is the final page.

---

## Memory Tools

### `memory_read`

Read the agent's persistent memory (or project-level memory).

| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| `scope` | string | | `"agent"` (default) or `"global"` |

Returns the contents of the memory file, or an empty string if no memory exists yet.

---

### `memory_write`

Append content to persistent memory. Entries are timestamped automatically.

| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| `content` | string | ✓ | Content to append |
| `scope` | string | | `"agent"` (default) or `"global"` |

Memory is stored in `.veil/memory/agents/<name>/MEMORY.md` (agent) or `.veil/memory/MEMORY.md` (global).

**Empty-content rejection (`skipped_empty`).** When `content` is null, an empty string, or whitespace-only, the tool returns `{ status: "skipped_empty", filePath: null }` without touching the file. No append, no timestamp, no audit row.

**Exact-entry dedup (`skipped_duplicate`).** Before appending, the tool compares the trimmed content against the last entry already in the file. If they match byte-for-byte (after trimming), the call returns `{ status: "skipped_duplicate", filePath }` and the file is not re-written. Substring matches and partial overlaps are NOT considered duplicates — only an exact re-append. This means writing "X is true" and later "X is true and well-known" both succeed; writing the same "X is true" twice in a row collapses to one entry.

**Tool vs HTTP asymmetry.** These guards live in the tool's execute path. The HTTP `PUT /memory/:file` endpoint (and `PUT /agents/:name/memory/:file`) takes the body content as-is and calls `fs.writeFileSync` — it does NOT apply the empty-rejection or exact-dedup guards. Use the tool from inside an agent run for the cleanup semantics; use the HTTP endpoints when you want a literal overwrite.

---

### `memory_search`

Search memory for content relevant to a query.

| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| `query` | string | ✓ | Search query |
| `scope` | string | | `"agent"` (default) or `"global"` |
| `maxResults` | integer | | Max matching entries (default: 5) |

Returns matching memory entries scored by relevance.

---

## Productivity Tools

### `todo_write`

Write a structured todo list for the current task.

| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| `todos` | array | ✓ | Array of todo items |

Each todo item:
```json
{
  "id": "1",
  "content": "Read the config file",
  "status": "pending",
  "priority": "high"
}
```

`status`: `"pending"`, `"in_progress"`, `"completed"`  
`priority`: `"high"`, `"medium"`, `"low"`

Todos are stored in the database and can be read with `todo_read`.

---

### `todo_read`

Read the current todo list for the active task or session.

No parameters.

Returns a formatted list of todos with statuses and priorities.

---

## Multi-Agent Tools

### `agent_spawn`

Start a new session with a subagent. Required fields: `agent`, `instance_name` (caller-unique label), `objective` (what the subagent should accomplish; injected into its system prompt). Optional: `success_criteria`, `overrides`, `initial_message`.

| Parameter | Type | Required | Description |
|-----------|------|----------|--------------|
| `agent` | string | ✓ | Agent name |
| `instance_name` | string | ✓ | Unique label for this subagent under the caller (lets you address it by name, e.g. via `agent_control`) |
| `objective` | string | ✓ | What this subagent should accomplish; injected into its system prompt under `## Objective` |
| `success_criteria` | string | | Optional completion guideline; injected under `## Success Criteria` |
| `overrides` | object | | Per-spawn LLM config. Currently accepts only `{ reasoning: { effort, max_tokens? } }` — sets the spawned session's `reasoning` value (per-session > agent default). See [API reference 05-sessions.md](../api/05-sessions.md#reasoning-unification) for the effort enum + cross-engine routing. The legacy `budget_override` and bare `thinking`/`effort` keys were removed in May 2026. |
| `initial_message` | object | | `{ message: string, async_inform?: boolean }`. If absent, no LLM turn fires (zero tokens; useful for deferred work). |

**Return format:** XML envelope (Phase 3 — agents parse XML more reliably than embedded JSON).

| `initial_message` | `async_inform` | Returns |
|---|---|---|
| absent | — | `<instance-name>`, `<session-id>` (session created with system prompt; no LLM turn) |
| present | false (default) | `<instance-name>`, `<session-id>`, `<content>...</content>` (first turn blocking) |
| present | true | `<instance-name>`, `<session-id>`, `<notice>...</notice>` (first turn async; subagent's response is delivered to the caller's session as a new user message when ready) |

When `async_inform: true`, the response arrives as a fresh user message in your session — see the asymmetric-delivery rules below. The static notice template returned in `<notice>` has been simplified (Meeting 010): it tells the calling agent its message was queued and that the response will be delivered when ready, without prescribing turn-end behavior.

**Seed wrapping (Meeting 010 Finding B).** Both branches of `agent_spawn` (sync `async_inform: false` and async `async_inform: true`) wrap `initial_message.message` identically to `agent_message`: `[Message from <agent> (session: <sid>)]: <body>`. The target's first user-row format matches every subsequent inter-agent message — disambiguation by sender prefix works regardless of which path created the seed.

**Multiple async_inform calls to the same target** are handled asymmetrically (Meeting 008.1): the FIRST one (msg#1) gets the dispatcher's final reply; subsequent calls (msg#2+) each get the first mid-run text content the LLM produces after their respective drain. **Mid-run replies are prefixed with a `<system-reminder>` block** noting the sub-agent is still running, so receiving agents can distinguish them from the final delivery. **§3.3.1 coincide rule:** if a msg#2+ is drained at the runChat's last iteration, its separate delivery is dropped (would duplicate msg#1's reply); the row is marked with audit text `(coincided with final reply — see msg#1 reply)`. **Multi-caller race (Finding A — Meeting 010 fix):** two concurrent `async_inform: true` calls to the same idle target now resolve atomically — exactly one becomes msg#1, the other becomes msg#2+.

**Spawn-depth guard:** if the caller's session is already at `spawn_depth >= max_spawn_depth` (resolved as `agent.budget.max_spawn_depth ?? settings.budget.max_spawn_depth ?? null`, plus the caller-session column for inheritance), `agent_spawn` returns `BUDGET_EXCEEDED` with a self-explanatory message and `details.remediation: 'edit_agent_or_caller_budget'`. Defaults to unlimited unless configured. Per-call `budget_override` is no longer accepted on `agent_spawn` — use `agent_control { action: 'update-session', params: { budget_override: {...} } }` to adjust limits on a target session, or set them at agent / harness level.

Returns `AGENT_NOT_FOUND` immediately if the agent does not exist; `VALIDATION_ERROR` if `instance_name` is already used in the caller's session.

---

### `agent_message`

Send a message to a specific subagent session. Phase 3 redesign — the `agent` parameter has been removed; the target agent is derived from the session row.

| Parameter | Type | Required | Description |
|-----------|------|----------|--------------|
| `sessionId` | string | ✓ | Session ID of the target subagent (from `agent_spawn`) |
| `message` | string | ✓ | Message content |
| `async_inform` | boolean | | If `true`, return a static notice immediately and dispatch the turn in the background. End your turn after the notice — the subagent's response will be delivered as a new user message. **A SECOND async_inform to a target with an in-flight dispatch returns the FIRST mid-run text response, NOT the final response.** Default: `false` (sync). |
| `timeout` | integer | | Sync-mode timeout in seconds (default **600** = 10 minutes). When `async_inform: true`, has **no default** — async dispatches run until natural completion or explicit `agent_control({action: "stop"})`. |
| `full_steps` | boolean | | When true AND this message becomes the seed (msg#1) of a fresh dispatch chain, the final delivery contains a per-line log of all assistant text + tool calls produced during the runChat. Silently ignored on follow-up async messages (msg#2+) — those replies are always plain first-text-after-drain content. Sync path also honors `full_steps` for the polled / idle-fast-path response. Default: `false`. |

**Routing logic (sync, default):**
- Target session is **idle** + mode is `chat` → triggers a new chat turn directly (same as a user message via API).
- Target session is **active** (mid-processing) → injects the message at the next iteration boundary; polls for a correlated response.
- Loop ends before picking up the message → falls back to `runChat` automatically.

**Asymmetric delivery (Meeting 008.1):**

- **msg#1** = the FIRST `async_inform: true` for a target (the seed of a dispatch chain). Reply = the **final** text of the runChat (or a `full_steps` log if requested). Delivered once when the runChat ends.
- **msg#2+** = any subsequent `async_inform: true` while the dispatch is in flight (from ANY caller, including a second caller hitting the same busy target). Reply = the **first** text the LLM produces after that message is drained at an iteration boundary. Delivered immediately mid-runChat. `full_steps` is silently ignored on msg#2+ replies.
- **Edge case:** if msg#2+ is drained right at the runChat's last iteration (whose text becomes msg#1's final reply), msg#2+'s separate delivery is dropped — the row is marked with audit text `(coincided with final reply — see msg#1 reply)` so the queue stays consistent.
- **Multi-caller note:** when caller-B sends `async_inform: true` to a target where caller-A is the seed, caller-B's reply is their own first mid-run text — caller-B does NOT see the dispatcher's final reply.

**Async + sync interactions (per-caller):**

- `sync-false` after `async-true` (same caller → target) → the in-flight dispatch is **NOT aborted** (would kill replies for other callers). Instead, the sync call's poll WHERE clause merges in this caller's queued correlation ids; whichever lands first satisfies the sync call.

**Sync `full_steps`:** when `async_inform: false` and `full_steps: true`, the sync return swaps the polled / idle-fast-path text for a `formatFullSteps` log of assistant turns + tool calls produced after the message arrived.

**Return format:** XML envelope (Phase 3 — agents parse XML more reliably than embedded JSON). Sync example:
```xml
<instance-name>planner_1</instance-name>
<session-id>sess_abc</session-id>
<finish-reason>stop</finish-reason>
<content>
the actual reply text — possibly multi-line
</content>
```
Async returns `<instance-name>`, `<session-id>`, and a `<notice>` block instead of `<content>`.

Returns `SESSION_NOT_FOUND` if the session does not exist; `SESSION_CLOSED` if it has been closed.

---

### `agent_control`

Inspect, modify, or stop a subagent session. Auto-paired with `agent_spawn`: any agent that lists `agent_spawn` in its `tools` (or `preActivatedTools`) automatically gets `agent_control` too.

| Parameter | Type | Required | Description |
|-----------|------|----------|--------------|
| `sessionId` | string | ✓ | Subagent's session ID |
| `action` | string | ✓ | One of `get-state`, `get-summary`, `stop`, `update-session`, `get-last-message` |
| `params` | object | | Action-specific args. See per-action notes below. |

**`get-state`** — cheap, no LLM call. Returns XML:
```xml
<is-idle>true</is-idle>
<number-of-messages>12</number-of-messages>
<number-of-tools>3</number-of-tools>
<instance-name>researcher_1</instance-name>
<session-id>sess_...</session-id>
```
Counters are **since the caller's last contact** (most recent `agent_message` to the session, or the spawn-time baseline marker, or the session's `created_at`).

**`get-summary`** — paid LLM call, default model from `config/config.json:defaultSummarizerModel` (currently `google/gemini-3-flash-preview`; operator-overridable via `settings.summarizerModel`). Returns the same `get-state` fields plus `<summary>` (2–3 sentence prose). **No default rate limit** — operator can opt in by setting `settings.summarizerMaxCallsPerMinute` to a positive integer; without it, polling is unbounded. When the cap is set and exceeded, returns the same envelope plus `<rate-limited>true</rate-limited>` and `<retry-after-ms>60000</retry-after-ms>`.

**`stop`** — cooperative cancel. Triggers the same cancel-registry path as `user_cancel`; dangling tool calls receive placeholder results so the session stays valid for inspection. Returns the same counter shape as `get-state`. Idempotent.

**`update-session`** — modify session-level config on a running or idle target. Accepts via `params`:
- `reasoning: { effort, max_tokens? }` — engine-blind reasoning override (see [05-sessions.md](../api/05-sessions.md#reasoning-unification) for the enum). Applied on the target's NEXT chat turn (resolveLLMParams reads from session row at runChat entry).
- `budget_override: { max_tokens?, max_wall_seconds?, max_spawn_depth? }` — per-session budget caps. Applied **immediately** to the running loop's per-iteration `resolveBudget` read.

Response includes `applied`, `applied_immediately` (budget keys), and `applied_next_turn` (reasoning) so the caller knows when each change takes effect.

**`get-last-message`** — read the target's last assistant text. With `params: { full_steps: true }`, returns the last-turn full-steps log (every assistant message + tool call since the last user turn) instead of just the last assistant message. Useful for orchestrators that need to inspect a sub-agent's recent reasoning without sending an `agent_message`.

---

## Utility Tools

### `log_write`

Write a progress log entry. Useful for agents to document milestones; the message is echoed back as the tool result.

| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| `message` | string | ✓ | Log message |
| `level` | string | | `"info"` (default), `"warn"`, `"error"` |
| `metadata` | object | | Optional structured data |

The log message is returned to the model as the tool result (a progress acknowledgement).

---

### `sleep`

Wait for a specified number of seconds. Time counts against `maxDurationSeconds` but not against `maxIterations`.

| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| `seconds` | number | ✓ | Seconds to wait (max: 300) |

Useful for polling loops or rate-limiting.

---

### `schedule_wakeup`

Schedule a self wake-up. After the delay elapses, the runtime injects `message` as a new user turn on the same session, so the agent resumes autonomously after ending its current turn. This is a **core tool available to all agents on both engines** (openai and claude-cli) — it used to be a per-agent custom tool.

| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| `time` | string | ✓ | Delay: `"30m"`, `"90s"`, `"2h"`, or a bare number (= minutes). `"0"` disables and removes any existing schedule. |
| `message` | string | required when `time` > 0 | Text delivered to the agent as a new user turn when the timer fires. |
| `loop` | boolean | | If `true`, re-arm and fire every `time` until cancelled. Default `false`. |

**Behavior:**
- There is **one schedule per session** — arming a new one replaces the previous.
- A **suspended** session keeps its timer armed but skips delivery until resumed; a **closed** session's timer is dropped (a looping timer stops re-arming).
- The schedule can also be inspected and controlled out-of-band via the HTTP API: `GET` / `POST` / `DELETE /sessions/:id/wakeup` (see [Sessions API](../api/05-sessions.md#wake-up-schedule--get--post--delete-sessionsidwakeup)).

```
schedule_wakeup({ time: "30m", message: "Re-check the deploy status" })
schedule_wakeup({ time: "2h", message: "Poll the queue", loop: true })
schedule_wakeup({ time: "0" })   // cancel
```

---

### `tool_search`

Search the list of available tools (built-in + any custom tools loaded for the agent). Returns full schemas so the LLM can inspect parameter requirements before deciding which tool to activate. **This is read-only — calling `tool_search` does not make any tool callable.**

| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| `query` | string | ✓ | Search term (matches name and description) |

Returns an array of matching tool schemas (name, description, input_schema).

---

### `tool_activate`

Make a specific custom tool callable by loading its full schema into the active tool list. Call this after `tool_search` once you have decided to use a tool.

| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| `name` | string | ✓ | Exact tool name to activate (as returned by `tool_search`) |

Returns `{ "activated": "<name>" }` when the tool exists, or a not-found message otherwise. After activation the tool is callable on the NEXT LLM turn (the same turn, since the activation push happens before the LLM is called again).

**Caveat — permission-denied activation is a silent-drop.** The `tool_activate` tool itself only checks that the tool *exists*; it returns success before the permission check runs. The per-agent allowlist / `disallowedTools` check happens downstream in the loop's activation step, and a blocked tool is silently omitted from the active tool list on the next turn. The LLM sees `{ activated: <name> }` but then gets "tool not found" on the call. **If your custom tool "doesn't show up" after `tool_activate` reported success, check your agent's `modes.<mode>.tools` allowlist first.** This is also flagged in the [Custom Tools](#custom-tools) section.

---

## Custom Tools

A custom tool is a **folder** containing a JSON manifest + a JS execute module. Each tool lives in its own subdirectory.

### Folder layout

```
tools/
└── my_tool/          ← one folder per tool (folder name is arbitrary)
    ├── tool.json     ← the schema manifest
    └── index.js      ← module.exports = async function execute(input) { ... }
```

### Discovery tiers (checked in this order when the agent runs)

| Tier | Path | Scope |
|------|------|-------|
| Agent-level | `.veil/agents/<agent>/tools/<name>/` | Only this agent can see it |
| Project-level | `.veil/tools/<name>/` | All agents in this project |
| Global | `~/.veil/tools/<name>/` | All agents across all projects on this machine |

A tool with the same `name` at a higher-priority tier overrides lower tiers.

### `tool.json` — the schema manifest

```json
{
  "name": "my_tool",
  "description": "One-line description — used by tool_search to match queries.",
  "input_schema": {
    "type": "object",
    "properties": {
      "input": { "type": "string", "description": "Input value" }
    },
    "required": ["input"]
  },
  "timeout": 30
}
```

- `name` must match the tool name the agent will call. Convention: match the folder name.
- `input_schema` is a JSON Schema object — validated with AJV at call time.
- `timeout` (seconds) is the per-call watchdog applied by the runtime. **Default 30 if omitted.**
- **⚠ Input-name reservation.** Do NOT declare any `input_schema` property starting with an underscore (`_cwd`, `_agent`, etc.). The runtime spreads injected context fields on top of `toolInput` when calling `execute`, so an underscore-prefixed input would be silently overridden. Stick to non-underscore property names.
- **⚠ Timeout + `_remoteMethodExecution` interaction.** If your tool calls `_remoteMethodExecution`, its `timeoutMs` (default 120 000 ms = 120 s) must be SHORTER than the tool's `timeout` (seconds × 1000). A tool with `timeout: 30` and a default-120-s remote-method call gets killed by the outer watchdog at 30 s before the remote response arrives. Rule of thumb: `tool.json`.`timeout` ≥ `_remoteMethodExecution`.`timeoutMs / 1000 + 5`.

### `index.js` — the execute module

**The export is the execute function directly**, NOT an object with `{schema, execute}`. The `{schema, execute}` shape is only for built-in tools loaded by `loadBuiltinTools` in `core/registry.js`.

```js
'use strict';

module.exports = async function execute({ input, _cwd, _agent, _sessionId, _settings, _modelKey, _emitToolChunk, _remoteMethodExecution }) {
  // Your tool logic.
  return `Processed: ${input}`;
};
```

### Injected context fields

Alongside the inputs declared in `tool.json`, the runtime injects these `_`-prefixed fields on every call:

| Field | Description |
|-------|-------------|
| `_cwd` | Absolute path to the session's instance folder (workspace). |
| `_agent` | Loaded agent config object (includes `name`, `agentFolder`, `modes`, etc.). |
| `_sessionId` | Current chat session ID. |
| `_settings` | Loaded settings object. |
| `_modelKey` | Resolved model identifier for this turn. |
| `_emitToolChunk(chunk)` | Optional callback — call it to stream live output to the SSE `tool.chunk` event as the tool runs. |
| `_remoteMethodExecution({ method, data, timeoutMs })` | Pause and wait for a UI response. See [Remote Method Execution](#remote-method-execution). |

### Return shapes (same as built-in tools)

- **String** — treated as plain text, fed to the LLM on the next turn.
- **`{ text, attachments: [...] }`** — structured multimodal return. Each attachment is `{ type, media_type, url?, data? }`. The LLM sees multi-part content on providers that support it (OpenAI); other providers see the text and log a warning.
- **`{ contentBlocks, displayText }`** — legacy multimodal shape also accepted.

### Discovery + activation flow

1. Custom tools are advertised to the LLM by **name + description only** in the system prompt (Tier 2) — parameter schemas are NOT loaded up front.
2. The agent calls `tool_search({ query })` to see full schemas of matches.
3. The agent calls `tool_activate({ name })` to push the tool's schema into the active `tools[]` list for the next LLM call.
4. The agent calls the tool normally.

### Pre-activating a custom tool (skip the search/activate dance)

If you know an agent will always need a specific custom tool, list it under `modes.<mode>.preActivatedTools` in `agent.json`. Pre-activated tools appear in the LLM's `tools[]` array on turn one with their full schema — no `tool_search` / `tool_activate` step required — and they are **omitted from the Tier 2 summary list** since they're already visible in `tools[]`.

```json
{
  "name": "my_agent",
  "modes": {
    "chat": {
      "enabled": true,
      "preActivatedTools": ["generate_audio", "render_layout"]
    }
  }
}
```

Resolution follows the same three-tier discovery as `tool_search`: agent → project → global. Pre-activation is **still subject to the permission layer** — entries that don't match the mode's `tools` allowlist (when present) or that appear in `disallowedTools` are logged and skipped at startup rather than silently failing at runtime. Entries naming a tool that doesn't exist are also logged and skipped.

Use pre-activation for tools the agent needs on every turn (e.g. a domain-specific renderer the agent's whole purpose revolves around). Leave the rest on the lazy search/activate path so the LLM's `tools[]` stays lean.

**Notes.** Listing a built-in tool name (e.g. `bash`) in `preActivatedTools` is a no-op — built-ins are already in `tools[]` from Tier 1. A harmless redundancy, but you'll see a one-line info log at startup noting the duplicate so you can clean up the config.

### Engine parity — claude-cli

Everything above describes the **openai** engine (default). On the **claude-cli** engine (agents configured with a `cc/…` model that routes through `@anthropic-ai/claude-agent-sdk`), the four-tier lazy-loading model doesn't apply. The Claude SDK takes its MCP tool list at `query()` construction time and cannot mutate it mid-session. Instead:

- **All discoverable custom tools are registered up front** at session start — the LLM sees their full schemas on turn one.
- **`tool_search` and `tool_activate` still work** but become reflective rather than gate-keeping. `tool_search` queries the on-disk registry; `tool_activate` is a no-op success (the tool was already callable).
- **`preActivatedTools` is a no-op** and emits a one-line info log so you know the config field was read but had no additional effect.
- **The `modes.<mode>.tools` allowlist gates custom tools only.** The hardcoded orchestration built-ins (agent_spawn, agent_message, memory_write, etc.) are always registered regardless of the allowlist — this preserves behavior for every shipped claude-cli agent whose allowlist doesn't list them.
- **`modes.<mode>.disallowedTools` gates both** custom tools (at wrap time) and all tool calls (via the SDK's own disallowedTools + the `canUseTool` callback).
- **Native tool base set is an allowlist.** By default (`settings.claudeCli.redirectBasicTools`, default on) claude-cli agents keep only native `Read` — it can *view* image files, which MCP tool results cannot, and is how temp-saved image attachments reach the model — while Veil's own `write_file` / `edit_file` / `bash` / `bash_output` / `kill_shell` / `glob` / `grep` / `list_dir` / `web_fetch` / `web_search` / `sleep` are exposed via MCP for everything else. When natives are re-enabled (`redirectBasicTools: false`, or an explicit `settings.claudeCli.nativeTools` list), only a fixed known-safe set is allowed; any tool a newer Claude Code SDK ships is auto-excluded unless it's in that set, so SDK updates can't leak new native tools into agents. `schedule_wakeup` and the other orchestration built-ins are always available via MCP regardless. See [Configuration → claudeCli](03-configuration.md#claudecli).
- **Native-collision guard:** a custom tool whose Veil name collides with a Claude-native equivalent (e.g. `read_file` maps to Claude's native `Read`) is NOT wrapped — the native takes precedence and the user is warned to rename their custom tool.
- **Rich return shapes:** `{ text, attachments }` and `{ contentBlocks, displayText }` are supported, but the MCP content surface back to the LLM carries only text — attachment metadata is serialized as appended JSON. For full multi-part attachment support, use the openai engine.

### Streaming events — engine-blind UI

The SSE event stream is designed so UIs never need to know which engine an agent is routed through. Every event the openai engine emits during a chat turn also fires on the claude-cli engine whenever the equivalent signal is available, with identical payload shapes. Specifically:

- **`inference.tool { name }`** — fires on both engines the moment the LLM commits to a tool call. On claude-cli this lands during `content_block_start` streaming (no longer waits for the full assistant message with serialized tool arguments, which for a multi-KB `Write` content could mean 10–60s of spinner silence).
- **`chat.message { role: 'tool', content, tool_call_id, id, session_id, created_at }`** — fires on both engines after a tool's result arrives. On claude-cli it's emitted alongside the existing `tool.end`. UI chat-message subscribers render tool bubbles off this event.
- **`tool.end { toolName, toolInput, output, outputPreview, success, durationMs }`** — same fields on both engines now. claude-cli's event-bus payload previously omitted `toolInput` and `durationMs`; that's fixed.
- **`thinking.chunk { content }`** — **new event**, fires on both engines when the provider streams reasoning / extended-thinking deltas. On claude-cli it fires for every `thinking_delta`. On the openai path it's best-effort: providers that stream `reasoning_content` (DeepSeek, GLM-4.5, o1-line, Anthropic via OpenRouter, …) trigger it; providers that don't stream thinking stay silent (same as today — thinking is still bundled into the final assistant `message.thinking_content`).
- **`chat.message { role: 'assistant' }`** — shape parity: both engines now emit `id`, `created_at`, `finishReason` (Claude's `stop_reason` mapped to openai vocabulary: `end_turn → 'stop'`, `tool_use → 'tool_calls'`, `max_tokens → 'length'`, `stop_sequence → 'stop'`), and `tokenUsage` when available.

**Intentionally not emitted (both engines):** the byte-by-byte `input_json_delta` stream while the LLM is still writing a tool call's arguments. This is a known silent window: the UI knows a tool call is coming (`inference.tool` fires early) but the arguments don't stream until the call is complete. Consistent across engines.

**claude-cli-specific extras:** `tool.progress { toolName, toolUseId, elapsed }` fires during native tool execution — unique to the claude-cli path, harmless for UIs to ignore.

**Mid-run injection safety (Meeting 011, Issue 2d).** When a chat HTTP call routes to a claude-cli session that's actively running, the injection is buffered until the SDK is between turns (parked on `streamInput`'s next-promise after a `result` message). The buffered messages are flushed in FIFO order via `pushMessage` once it's safe — preserving the in-flight tool_result and any partial assistant text. The openai engine has equivalent behavior via the natural iteration boundary in `drainNonFollowup`. End-user impact: msg#2+ delivery on claude-cli now arrives as a single user message AFTER the current turn's tool_result lands, not in the middle of it. Latency: up to one tool-call duration.

**Context size on claude-cli (Meeting 011, Issue 1).** `sessions.context_size` now correctly counts the FULL prompt window — `input_tokens + cache_read_input_tokens + cache_creation_input_tokens + output_tokens` — instead of just `input_tokens + output_tokens`. Pre-fix, claude-cli sessions showed ~0–5% context usage forever once the prompt cache warmed; auto-compaction never fired. Post-fix, the percentage tracks reality and `defaultCompaction.autoThreshold` triggers as documented.

### Complete minimal example

Creating a project-level tool `echo_upper` that uppercases its input:

```
.veil/tools/echo_upper/
├── tool.json
└── index.js
```

`tool.json`:
```json
{
  "name": "echo_upper",
  "description": "Return the input string uppercased.",
  "input_schema": {
    "type": "object",
    "properties": {
      "message": { "type": "string", "description": "String to uppercase" }
    },
    "required": ["message"]
  },
  "timeout": 5
}
```

`index.js`:
```js
'use strict';
module.exports = async function execute({ message }) {
  return message.toUpperCase();
};
```

From the agent's side, after `tool_search("echo")` → `tool_activate("echo_upper")`, it can call `echo_upper({ message: "hello" })` and receive `"HELLO"`.

### Calling the LLM from a custom tool

A custom tool can run its own LLM calls in-process — no HTTP self-call to `/completions` needed. Use `callWithProviderFallback` from [`llm/provider.js`](../../llm/provider.js); it goes through the same provider resolution + fallback chain the main loop uses, with the runtime's resolved per-turn model.

```js
'use strict';
const { callWithProviderFallback } = require('../../../llm/provider');
const { extractMessage } = require('../../../llm/client');

module.exports = async function execute({ text, _settings, _modelKey }) {
  if (!_modelKey) {
    return 'Error: TOOL_CONTEXT_INVALID — _modelKey not injected (LLM tools require chat context).';
  }
  try {
    const result = await callWithProviderFallback({
      settings: _settings,
      modelId:  _modelKey,                    // per-turn model — honors session + per-call overrides
      messages: [
        { role: 'system', content: 'Summarize the user\'s text in one sentence.' },
        { role: 'user',   content: text },
      ],
      reasoning: { effort: 'low' },           // optional; reasoning-model providers honor it, others ignore
    });
    const { content } = extractMessage(result.response);
    return (content && content.trim()) || '(empty)';
  } catch (err) {
    return `Error: LLM_ERROR — ${err.message}`;
  }
};
```

**Use `_modelKey`, not `_agent.model`.** `_modelKey` is the per-turn model resolved by the runtime (per-call > session > agent > settings). Reading `_agent.model` would ignore any session-level or per-call override — your tool would silently call a different model than the rest of the turn.

**`extractMessage` returns** `{ content, thinkingContent, audio, toolCalls, finishReason }`. For a no-tools call, `content` is the assistant text; the others are usually `null` / `[]`.

**Cost / budget rails — important.** In-process tool calls go through the same providers as agent turns, so:
- ✅ Provider billing & rate limits apply (same API quota).
- ❌ The session's `token_budget` and harness budgets are **not** auto-deducted (only the main loop's own calls are tracked at [`core/loop.js`](../../core/loop.js)).
- ❌ No row is persisted in the session's `messages` table.

If your tool needs to honor a session budget cap, read `sessions.token_budget` yourself and short-circuit before the LLM call.

**Streaming**. Pass `onChunk: (text) => {...}` to stream tokens as they arrive (matches `core/loop.js`'s usage). The return value shape is unchanged.

**Engine parity.** This pattern works identically on openai-engine and claude-cli-engine sessions — both inject `_modelKey`, and `callWithProviderFallback` resolves the right provider chain regardless. Custom tools that depend on `_modelKey` need no engine-specific branching.

A complete runnable example lives at [`examples/tools/summarize_text/`](../../examples/tools/summarize_text/). For the wire-level `/completions` HTTP endpoint (useful for non-tool integrations), see [API reference 10-completions.md](../api/10-completions.md).

### Permission gating

Per-agent `modes.<mode>.tools` allowlist and `disallowedTools` blocklist apply to custom tools the same way they apply to built-ins. If an agent has a non-empty `tools:` allowlist, the custom tool's `name` must appear in it — otherwise `tool_activate` silently fails (the runtime returns "activated" but the tool isn't actually callable; see the "gotchas" note below).

**Gotcha:** `tool_activate` currently returns `{ activated: <name> }` even when the permission check downstream rejects the tool. The agent receives a success signal but the tool still isn't callable on the next turn. If your tool "doesn't show up" after `tool_activate`, check your agent's mode allowlist first.

---

## Remote Method Execution

`_remoteMethodExecution` lets a custom tool **pause and wait for a response from a connected UI**. This is the mechanism for building interactive UI-side tools — for example, a tool that opens a dialog in the dashboard and resumes once the user answers.

### How it works

```
Tool calls _remoteMethodExecution()
        │
        ▼
Server emits  method.pending  over SSE
        │
        ▼
UI receives event, shows dialog / performs action
        │
        ▼
UI posts result to  POST /remote-methods/:id/result
        │
        ▼
Server broadcasts  method.done  to all SSE clients
        │
        ▼
_remoteMethodExecution() resolves with the result
        │
        ▼
Tool continues execution
```

Methods live **in memory only** — they do not survive a server restart.

### Tool-side usage

```js
async execute({ question, _remoteMethodExecution }) {
  const response = await _remoteMethodExecution({
    method: 'user-dialog',          // arbitrary string — UI uses this to decide how to render
    data: { question },             // any JSON-serialisable value the UI needs
    timeoutMs: 120_000,             // optional, default 120 s; throws on timeout
  });
  // response is whatever the UI sent as body.result (any JSON value)
  return `User answered: ${JSON.stringify(response)}`;
}
```

**If no UI is listening** the call will hang until `timeoutMs` elapses, then the tool receives a rejection error. Design your tool to handle this gracefully:

```js
try {
  const answer = await _remoteMethodExecution({ method: 'ask_user', data: { prompt } });
  return `User said: ${answer}`;
} catch (err) {
  return `No UI responded in time: ${err.message}`;
}
```

### API endpoints

#### `GET /remote-methods`  *(SSE)*

Connect to receive a stream of method events. On connect, all currently-pending methods are replayed so a freshly-connected UI is immediately in sync.

**Event types**

| type | fields | meaning |
|------|--------|---------|
| `method.pending` | `id`, `method`, `data`, `createdAt` | A tool is waiting for a result |
| `method.done` | `id`, `timedOut` | Method was resolved (or timed out) — discard it |

```
: connected

data: {"type":"method.pending","id":"550e8400-e29b-41d4-a716-446655440000","method":"user-dialog","data":{"question":"Continue?"},"createdAt":"2025-01-01T00:00:00.000Z"}

data: {"type":"method.done","id":"550e8400-e29b-41d4-a716-446655440000","timedOut":false}
```

Headers required: `Authorization: Bearer <secret>` (same as all other endpoints).

#### `POST /remote-methods/:id/result`

Deliver the UI's response for a specific pending call.

**Body**
```json
{ "result": <any JSON value> }
```

**Responses**

| Status | Meaning |
|--------|---------|
| `200 { ok: true, id }` | Result accepted, waiting tool unblocked |
| `409 { error: "not_found" }` | ID unknown — already resolved, timed out, or invalid |

#### `GET /remote-methods/pending` *(diagnostic)*

Returns `{ pending: ["id1", "id2", ...] }` — the IDs of all currently-waiting calls.

### UI implementation example

```js
// Connect to the SSE stream
const es = new EventSource('/remote-methods', {
  headers: { Authorization: `Bearer ${secret}` }
});

es.onmessage = async (e) => {
  const event = JSON.parse(e.data);

  if (event.type === 'method.pending') {
    if (event.method === 'user-dialog') {
      // Show your dialog
      const answer = await showDialog(event.data);

      // Post the result back
      await fetch(`/remote-methods/${event.id}/result`, {
        method: 'POST',
        headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${secret}` },
        body: JSON.stringify({ result: answer }),
      });
    }
  }

  if (event.type === 'method.done') {
    // Optionally close/dismiss any pending dialog for event.id
    dismissDialog(event.id);
  }
};
```

### Multi-client behaviour

If multiple UIs are connected simultaneously:

- **All** receive `method.pending` — the **first** to POST a result wins.
- The server immediately broadcasts `method.done` to all remaining clients so they can discard the prompt.
- A late POST returns `409 not_found` — handle this gracefully on the UI side.

### Timeouts

The default timeout is **120 seconds**. Override per-call:

```js
await _remoteMethodExecution({ method: 'confirm', data: {}, timeoutMs: 30_000 });
```

When a call times out, `method.done` with `timedOut: true` is broadcast to all SSE clients.

---
