---
title: Tools & Approval
description: The deliberately minimal built-in toolset, its execution contract with centralized close-out, and per-call approval audited in the Trace.
---

## Design

PenguinHarness ships a deliberately minimal built-in toolset: dedicated file tools (`read_file` / `edit_file` / `write_file`) cover precise reading and editing — line-numbered output and exact-string replacement beat quoting `sed` one-liners — while the shell (`exec_command`) remains the general-purpose fallback for everything else: running programs, searching, installing dependencies. Every tool that remains earns its schema tokens.

## Execution contract

Every built-in tool implements the same `BuiltinTool` interface (`packages/core/src/environment/tools/types.ts`):

```ts
interface BuiltinTool {
  name: string;
  definition: ToolDefinitionConfig;
  execute(
    args: Record<string, unknown>,
    ctx: ToolExecutionContext,
  ): AsyncGenerator<OmniMessage, ToolResult | void>;
}

interface ToolExecutionContext {
  workspaceDir: string;
  toolCallId: string;
  signal?: AbortSignal;
  approve?: ApproveFn; // forwarded to tools that spawn child Sessions (approval inheritance)
}

interface ToolResult {
  stopReason?: StopReason; // the tool's self-reported terminal state (lowest priority, see below)
  note?: string; // terminal marker appended outside the truncation window (e.g. exit code)
  images?: string[]; // data-URL images, appended after the text output
}
```

A tool only yields incremental `partial_tool_call_output` deltas; the Environment handles the close-out centrally:

- streaming framing (start / stop) and `tool_call_id` threading;
- timeout merging, and head-kept truncation once output exceeds `maxOutputLength` (default 16000 characters);
- stop_reason priority: user interrupt > timeout > tool throw > tool self-report;
- never-empty output (`[no output]` is substituted when a tool produced nothing);
- `note` (e.g. the exit code) and images are appended outside the truncation window, so the terminal marker survives even when long output is cut.

Tools and the Environment never throw into the engine: errors collapse into `tool_call_output` messages the model can read and react to. See the [OmniMessage Protocol](/omni-message) for message structure.

### Recovering oversized output

When tool text in an Agent Session exceeds `maxOutputLength`, the model and Web/CLI still receive the same head window, truncation marker, and terminal marker, and the streaming invariant that user-visible output equals model-visible output does not change. Environment also appends a short archive status/path note outside that visible-output cap and saves a Session-owned recovery file. The file is exact within the per-call archive budget and otherwise contains bounded head/tail windows. This is the complete text **received by Environment**: a producer such as a command or subagent session may already have replaced overflow with an `[..., N chars of earlier output dropped ...]` marker in its own bounded unread buffer, and the downstream archive cannot recover text lost before that point.

The Agent can inspect ordinary multiline archives with the existing `read_file` (`offset` / `limit`). For byte tails or very long lines, it must construct a targeted shell command such as `rg` / `tail`; no dedicated retrieval tool is added. The note carries a plain absolute path, always the last element inside the bracket. On Windows it is written with forward slashes: `exec_command` runs through (Git) Bash and Node's fs APIs accept them, so one spelling works in JSON tool arguments and shell commands alike; POSIX paths pass through unchanged, and Session paths are ordinary absolute paths (never `\\?\`-prefixed), so the separator swap is lossless. As with any path, quote it inside shell commands when it contains spaces. The same spelling rule covers every path core composes for the model — the system prompt's App Data Dir / CWD lines, `[attached image/file: …]` lines and the goal-file line (`modelVisiblePath` in the SDK).

Recovery files live under the Session's `scratchpad/<session-id>/truncated-tool-output/`, are created only after actual truncation, and use private permissions where the platform supports them. One call stores at most 8 MiB (the production byte limit is one byte lower so `read_file` remains below its 8 MiB scan cap); larger output keeps bounded head/tail windows in the file with an explicit middle-gap marker. The limit is per call only: a Session has no aggregate archive byte or file-count quota, and concurrent captures independently retain up to one call's budget. Files remain readable across Tasks, runtime disposal, and Session resume until explicit Session deletion removes the entire scratchpad; no separate archive cleanup lifecycle is added.

Recovery files contain the unredacted tool text received by Environment. Accidentally reading credentials or other sensitive data can therefore increase local at-rest retention from the visible head window to the archive budget. Trace does not duplicate those bytes, but it records the same absolute Session path shown to the model and Web/CLI, exposing the host's data-root layout. Archive-write failure never changes the original tool's `stop_reason`; the visible note and stderr warning carry only a short error code (and stderr's tool name), not the path or raw error message.

## Configuration fields

Each tool is described by one `ToolDefinitionConfig`:

| Field | Meaning |
| --- | --- |
| `name` | Tool name, matching the model's `tool_call.name` |
| `description` | Tool description handed to the model |
| `parameters` | JSON Schema of the arguments |
| `permission` | `"r"` read-only / `"rw"` read-write |
| `forModel` | `"vision"` / `"text-only"`: selected by the Session model's class; omitted = available to all models |
| `timeoutMs` | Per-call timeout (ms), default 120000; `<=0` disables |
| `maxOutputLength` | Output length cap (characters); `<=0` disables |
| `call_description` | Per-tool toggle for the `description` call argument declared in `parameters` (required while on); missing = kept, `false` filters it and its `required` entry out of the schema at assembly |

## Built-in tools

There are 9 built-in tools (assembled via `packages/core/src/environment/tools/registry.ts`):

| Tool | Permission | Timeout (ms) | Purpose |
| --- | --- | --- | --- |
| `exec_command` | rw | 120000 | Run a shell command in the Workspace via `bash -lc`, streaming stdout/stderr |
| `input_command` | rw | 130000 | Drive a running command by `process_id`: write stdin, send Ctrl-C, poll output |
| `read_file` | r | 30000 | Read a text file as a line-numbered (`cat -n`) window, paged by offset/limit |
| `edit_file` | rw | 30000 | Exact-string replacement in an existing file, echoing a verification snippet |
| `write_file` | rw | 30000 | Create or overwrite a whole file, creating parent directories as needed |
| `run_subagent` | rw | 600000 | Delegate a self-contained subtask to a child Agent in the same Workspace |
| `input_subagent` | rw | 600000 | Poll a background subagent, or send a follow-up prompt once it is idle |
| `read_image` | r | 60000 | Read an image and return it as image content (vision models) |
| `describe_image` | r | 90000 | Have the configured `vision_model` read the image and answer in text (text-only models) |

Note that an existing agent's persisted `tools.builtin` list is frozen as written (the settings UI edits rows but adds none): agents created before this toolset do not pick up the file tools automatically — hand-edit the agent's `system_config.yaml` and add the new entries (copy them from the default definitions in `packages/core/src/state/default-config.ts`) to adopt them.

### Call descriptions

The command/subagent tools (`exec_command`, `input_command`, `run_subagent`, `input_subagent`) take a `description` argument: one model-written sentence about what the call is doing, shown by the CLI and Web UI while the call runs. The argument is declared as a normal `description` property in each entry's `parameters` in `system_config.yaml` (tool schemas live entirely in the editable config), and it is **required** there — a tool that offers the argument always gets one, so the frontends can pick a call's display form from the schema instead of guessing while the arguments stream; the model is also asked to emit it first. The per-entry `call_description` field toggles the whole thing — missing = kept, `call_description: false` filters the property (and its `required` entry) out of the schema at assembly time (in-memory only, the YAML is never rewritten). The file tools don't take it — their `file_path` argument is self-describing.

### Command sessions

`exec_command` waits in the foreground first; if the command outruns `yield_time_ms` it moves to the background and the call returns the output so far plus a `process_id`, driven from then on by `input_command`:

```text
exec_command(cmd)
  ├─ finishes within the foreground window (yield_time_ms, default 60000)
  │        ──► full output + exit code
  └─ still running ──► backgrounds, returns output so far + process_id
                     │
    input_command(process_id[, chars]) ──► write stdin / send Ctrl-C / poll
                     └─ loop until the command exits
```

Both tools' arguments (explicit keys):

```ts
// exec_command
{
  cmd: string;             // required: the shell command to run
  workdir?: string;        // working directory; defaults to the Workspace root, relative paths resolve against it
  yield_time_ms?: number;  // foreground wait; default 60000, minimum 250, capped below the tool timeout
  description: string;     // required while call_description is on: one sentence shown to the user while the call runs, emitted first
}

// input_command
{
  process_id: string;      // required: the command-session id returned by exec_command
  chars?: string;          // characters for stdin; send "\u0003" alone to deliver Ctrl-C; empty = poll only
  yield_time_ms?: number;  // wait; defaults 250 for writes, 5000 for empty polls
  description: string;     // required while call_description is on
}
```

On POSIX, Ctrl-C sends `SIGINT` to the session's process group, interrupting the foreground command. On Windows there is no console signal delivery to a piped child process, so Ctrl-C degrades to a hard kill of the whole command session tree (`taskkill /t /f`) — the foreground command and every child it started terminate, instead of the foreground command being interrupted.

### File tools

`read_file` / `edit_file` / `write_file` run with the user's full permissions, same as the shell tool; relative paths resolve against the Workspace and absolute paths are allowed. They are non-streaming (a single final output) and never throw — failures come back as explanatory text with `stop_reason: failed`.

```ts
// read_file — cat -n style output (line number, tab, content); overlong single lines are
// truncated, and binary content (NUL bytes) is rejected with advice to use shell/image tools.
{
  file_path: string;       // required: absolute, or relative to the Workspace
  offset?: number;         // 1-based line to start from; default 1
  limit?: number;          // max lines returned; default 2000 — a trailing note points at the continuation
}

// edit_file — the file must exist; old_string must occur exactly once (or set replace_all);
// success echoes "Replaced N occurrence(s)" plus a git-style unified diff of the changed
// regions (one hunk per site, nearby sites merged; replace_all storms are capped at a few
// hunks plus an "…and N more replacements" note).
{
  file_path: string;       // required
  old_string: string;      // required: exact text to replace, including whitespace/indentation
  new_string: string;      // required: must differ from old_string
  replace_all?: boolean;   // replace every occurrence; default false
}

// write_file — creates parent directories as needed; reports "Created" vs "Overwrote" with
// lines/bytes. An overwrite also shows a small unified diff against the previous content,
// or a one-line +X/−Y summary when the change is large.
{
  file_path: string;       // required
  content: string;         // required: full file content; an empty string creates an empty file
}
```

### Subagents

`run_subagent` hands a subtask you can fully specify in one prompt to a child Agent, with the same two-phase shape: after the foreground window (default 300000ms) it moves to the background with a `subagent_id`, driven by `input_subagent` for polling or follow-up prompts; the child's pending approvals surface while the poll waits.

```ts
// run_subagent
{
  prompt: string;          // required: the complete subtask (all context + the exact final output expected)
  agent_id?: string;       // the child Agent; defaults to the current Agent
  model_id?: string;       // the child Session's model, paired with provider; omit both to inherit the parent Session's model
  provider?: string;       // the provider group model_id belongs to; required whenever model_id is given
  yield_time_ms?: number;  // foreground wait; default 300000
  description: string;     // required while call_description is on
}

// input_subagent
{
  subagent_id: string;     // required: the background Subagent id returned by run_subagent
  prompt?: string;         // follow-up task, accepted only while the child Session is idle; empty = poll only
  yield_time_ms?: number;  // wait; defaults 300000 with a prompt, 10000 for empty polls
  description: string;     // required while call_description is on
}
```

- Depth is capped at 1: a subagent cannot spawn another subagent.
- The child Session follows the parent Session — its model (unless `model_id`/`provider` pick another), thinking level, and Workspace — never the Project defaults.
- The child Session inherits the parent Agent's approval callback, so the approval mode follows the parent.
- The child Session gets its own Trace, linked from the parent by a `subagent` pointer event; child messages stream back into the parent flow tagged with `origin`. See [Sessions & Traces](/sessions-and-traces).

### Image tools

`read_image` and `describe_image` are mutually exclusive, selected by the Session model's vision flag. Both accept an http(s) URL or a Workspace path and support png/jpeg/gif/webp up to 5MB. Text-only models get `describe_image`: the image plus a prompt are forwarded to the Project's configured `vision_model`, whose text answer becomes the tool output. See [Models & Providers](/models).

```ts
// read_image (vision models)
{
  source: string;          // required: an http(s) URL, or a file path inside the Workspace
}

// describe_image (text-only models)
{
  source: string;          // required: as above
  prompt?: string;         // what to ask about the image; defaults to a detailed description
}
```

### Background session caps

| Session type | Cap | Eviction |
| --- | --- | --- |
| Command sessions | 64 | When full, exited sessions are evicted first, then idle ones by LRU |
| Subagent sessions | 8 | Only completed ones are evicted; running subagents never — with no room, spawning is rejected |

## Approval

Every complete `tool_call` triggers exactly one approval decision:

```ts
type ApproveFn = (toolCall: OmniMessage<ToolCallPayload>) => Promise<"allow" | "deny">;
```

| Surface | Behavior |
| --- | --- |
| SDK | Pass `approve` per `session.run`; with none injected the engine denies by default (conservative — nothing gets approved unattended) |
| CLI | `--approve` takes four modes: allow-all (default) / deny-all / read-only / always-ask; read-only auto-approves `permission: "r"` tools and defers the rest to a human |
| Web / Server | The same four modes, set per Session; the mode is re-read from the DB on every decision, so changes take effect immediately; manual decisions arrive via the API |

A deny produces a synthetic aborted `tool_call_output` (`Tool call denied by user.`) for the model to react to. Every decision is written to the Trace as an `approval_decision` event, forming a complete audit record. Approval happens in the tool-execution phase of the [Agent Loop](/agent-loop).

## Custom tools & MCP

The `tools.builtin` array in `system_config.yaml` declares the toolset with entries of the same `ToolDefinitionConfig` shape. The semantics are **wholesale replacement, not merging**: omit the section entirely to keep the full default toolset; once written, the default list is replaced and every tool you keep must carry its complete definition (including the `parameters` JSON Schema — a tool's schema comes entirely from config). `tools.mcpServers` carries MCP server configs (name + config) — enumerating concrete MCP tools is reserved for a later adapter layer and not yet wired. See [Configuration](/configuration).

```yaml
tools:
  # Writing builtin replaces the default toolset wholesale (this example deliberately
  # keeps a minimal single-tool set).
  builtin:
    - name: exec_command
      description: Run a shell command in the workspace.
      permission: rw
      # Optional per-tool toggle: false filters the `description` call argument
      # (declared in parameters.properties) out of the schema (missing = kept).
      call_description: false
      timeoutMs: 120000
      maxOutputLength: 16000
      # parameters: the complete JSON Schema is required (see the default definition
      # in packages/core/src/state/default-config.ts); elided here.
  mcpServers: []
```
