---
title: Coding agents and shell tools
summary: "A workspace agent with bounded shell output and host-owned tool policy."
canonical: https://docs.caveman.so/docs/agent-sdk/coding
license: Apache-2.0
capability: agent-sdk
updated: 2026-09-16T21:32:40-07:00
basis: inferred
---

# Coding agents and shell tools

> A workspace agent with bounded shell output and host-owned tool policy.
`shellTools` builds the six workspace tools any agent can hold: `bash`, `read_file`, `grep`, `write_file`, `edit_file`, and `read_tool_output`. Each one caps its raw output before anything else looks at it, and `RunOptions.toolPolicy` decides, outside model output, which calls are allowed to run at all. `@caveman-ai/coding-agent` is the whole interactive session built on them.

```ts
import { agent, auto, run, shellTools } from "@caveman-ai/agent";

const reviewer = agent({
  id: "reviewer",
  instructions: "Read the workspace and explain what changed.",
  model: auto(),
  sandbox: "host",
  tools: shellTools({ workspace: process.cwd(), tools: ["bash", "read_file", "read_tool_output"] }),
});

await run(reviewer, "count the lines in notes.txt");
```

Unreleased: this page documents `@caveman-ai/agent` 0.2.0 from source at commit 311be40 in caveman-ai/agent-sdk. Build it from the checkout as described on [Install](/docs/agent-sdk/install).

`workspace` is a hard boundary: every candidate path is canonicalized and compared against the workspace's real path, so a symlink out of it is refused. `sandbox: "host"` is required here, because these closures need real host access. Host execution runs tool code with your own privileges, and [Sandbox and execution](/docs/agent-sdk/sandbox) is where the containment options live.

## Bounded output

Every tool truncates its own raw output before any transform runs, so the cap on a runaway `cat` holds with the engine absent. `cat` of a 4,000-line file, captured from a scripted run:

```console
line 3998
line 3999
[caveman-code: output capped from 38889 captured bytes; use read_tool_output with handle tool_7eabe62d…]
```

| Tool | Cap, raw bytes |
|---|---|
| `read_file`, `bash`, `read_tool_output` | 24,000 |
| `grep` | 16,000 |
| `write_file`, `edit_file` | 2,000 |

Override any of them with `outputCaps`. The handle appears when `read_tool_output` is one of the tools you selected, and an over-long result is then stored and pageable. Leave that tool out and a capped result says it was capped and stops there. The store is in memory for the life of the agent, 8 MiB per entry and 16 MiB in total, oldest evicted first.

`bash` times out at 120 seconds and captures at most 4 MiB. `grep` returns at most 200 matches. Interactive bash sessions are local-backend only: at most 8 at a time, 30 seconds of wait per read, 64 KiB of input.

## Deciding which calls run

`toolPolicy` is your authorization decision. It runs after the kernel's own admission checks (deadline, budget, caps, breakers, sandbox posture, argument shape) for every declared tool call, in the root agent, in subagents, and in nested calls from a composite tool. Framework `cave_*` tools bypass it.

```ts
await run(reviewer, "count the lines", {
  toolPolicy: ({ name, effect, agentPath }) =>
    effect === "write" ? { deny: "read_only_review" } : undefined,
});
```

```console
policy: {"name":"bash","effect":"external","agentPath":[]}
policy: {"name":"write_file","effect":"write","agentPath":[]}
receipt.tools: [
  { "name": "bash", "calls": 1, "errors": 0 },
  { "name": "write_file", "calls": 1, "errors": 1, "denied": 1 }
]
```

A denied call stops before execution. The model reads `cave_tool_denied:read_only_review` as the tool result, the receipt counts it under `denied`, and the run continues. `undefined` or `{ allow: true }` admits the call, and a policy decides admission alone, because the durable journal binds each call to the digest of the arguments it was admitted with.

The input carries `runId`, `agentId`, `agentPath` (the subagent tool names leading to this call), `toolCallId`, `parentToolCallId` for a nested call, `name`, `effect`, and the validated `args`.

An answer the runtime cannot read ends the run before the tool. A policy that throws, returns a malformed decision, or takes longer than `TOOL_POLICY_TIMEOUT_MS`, which is 10,000 ms, stops there: `cave_tool_policy_failed`, `cave_tool_policy_decision_invalid`, or `cave_tool_policy_reason_invalid` for a deny code outside `[a-z][a-z0-9_]*`. Deny codes are short identifiers rather than tenant text, so receipts and journals stay content-blind.

`decideToolCall(policy, input)` is the same evaluator, exported so a deployment can test its own policy on its own:

```ts
import { decideToolCall } from "@caveman-ai/agent";

const decision = await decideToolCall(policy, {
  runId: "r1", agentId: "reviewer", agentPath: [], toolCallId: "c1",
  name: "write_file", effect: "write", args: { path: "out.txt" },
});
console.log(decision);
```

```text
{ block: true, reason: 'cave_tool_denied:read_only_review' }
```

On a durable resume the policy is evaluated again, before replay. Denying a call the crashed attempt already settled ends the resume with `cave_durable_tool_replay_incomplete` rather than skipping it quietly: [Durable runs](/docs/agent-sdk/durable).

## The interactive coding agent

```bash
npm install <checkout>/packages/agent <checkout>/packages/coding-agent
npx caveman-code --workspace .
```

```console
Usage: caveman-code [options]

Options:
  --workspace <path>    workspace root (default: current directory)
  --model <id>          provider/model override
  --observe-only        disable Cave runtime transforms
  --max-cost-usd <usd>  best-effort per-turn public-catalog spend cap
  --no-start-runtime    probe runtime without trying to start it
  -h, --help            show help
```

The library form is the same session, and the repository's whole `examples/coding-agent` is this:

```js
#!/usr/bin/env node
import { createCodingAgent, runCodingSession } from "@caveman-ai/coding-agent";

export async function main(options = {}) {
  const agent = createCodingAgent({ workspace: options.workspace ?? process.cwd() });
  return runCodingSession({ agent, ...options });
}

if (process.argv[1] && import.meta.url === new URL(`file://${process.argv[1]}`).href) {
  main().catch((error) => {
    process.stderr.write(`caveman-code: ${error instanceof Error ? error.message : error}\n`);
    process.exitCode = 1;
  });
}
```

`createCodingAgent` returns `{ definition, plan, workspace, modelID, toolMode, samples, close() }`. The definition is an ordinary `agent()` with `sandbox: "host"`, `reasoning: "off"`, the six shell tools, and a default efficiency plan that only uses routes whose output can be recovered exactly. `runCodingSession` adds the prompt loop, the per-turn bill, and the observe-only banner.

Useful options on `createCodingAgent`: `model` (`provider/model`, else `CAVE_MODEL`, else the configured provider), `instructions` appended to the built-in coding instructions, `outputCaps`, `executionBackend` to run the tools elsewhere, `memory`, and `toolMode: "programmatic"` to collapse the six tools into one code cell ([Programmatic tools](/docs/agent-sdk/code)).

`runCodingSession` takes `maxCostUsd` or `budget` (not both), `breakers`, `modelRouter`, `cacheRetention`, `onNotice`, and `cave: "off"` to skip the local runtime entirely.

## Limits

Without the local Caveman engine and gateway, a session runs straight to your provider and announces that it is in observe-only mode on every prompt. Provider usage and local context estimates still work; context transforms and gateway telemetry are off. Reductions a session does report are `inferred`, which [Numbers and limits](/docs/counting) defines, and none of them become verified savings.

The tool output store never crosses a process restart, so a handle from an earlier session is gone. Two coding agents in one workspace can stale each other's reads; give each one its own worktree.
