# claude-code

Claude Code as an iii worker: the Claude Code API exposed as functions and trigger types on the iii bus, nothing else. The worker spawns the same `claude` binary the user runs in their terminal, with the same login, the same filesystem, and the same tools (file edits, shell, web). `claude::run` executes one headless turn and returns the result; the raw Claude Code messages are published verbatim on the `claude::raw-event` trigger type, and a translated AgentEvent view on `claude::agent-event`, so the iii console, the acp worker, and any sibling worker observe a Claude Code run exactly like a native harness turn. The worker also registers `run::start_and_wait`, the same entrypoint the console and the acp worker drive, so both run Claude Code with no changes.

The same worker also runs Claude Code as a **terminal on the console**: `claude::terminal::*` installs the CLI on the terminal host, equips a workspace with the iii skills, and opens Claude in a `shell::pty` session on its own page — always Claude, never a shell. Both halves report onto one events stream, so a headless turn and a typed turn look the same in the console. Whether they also share one login depends on where each half runs: from a local checkout both are host processes and read the same `~/.claude`, while a worker installed from the registry runs the headless half in a microVM that cannot (see [Logging in](#logging-in)). Its sibling [`pi`](https://github.com/iii-hq/workers/tree/main/pi) is the same shape for the pi agent.

## Install

```bash
iii trigger compose::add worker=claude-code
```

Requires the `claude` CLI on the host (the Agent SDK shells out to it) and either `ANTHROPIC_API_KEY` in the worker environment or an existing `claude` login.

The terminal half adds three companions:

| Worker | Why |
|---|---|
| [`shell`](https://github.com/iii-hq/workers/tree/main/ide) ≥ 0.12 | Owns the terminal. `shell::pty::open` runs the CLI, `shell::exec` installs it and equips the workspace, `coder::read-file` reads the files back. The workspace must be inside shell's jail (`fs.host_roots`). |
| [`console`](https://github.com/iii-hq/workers/tree/main/ade) | Renders the injected **claude** page. |
| `configuration` (built in) | Holds the settings below. |

## Skills

Install the `claude-code` agent skill for Claude Code, Cursor, and 30+ other agents:

```bash
npx skills add iii-hq/workers --skill claude-code
```

## Quickstart

From zero to a Claude Code turn over the bus:

```bash
curl -fsSL https://install.iii.dev/iii/main/install.sh | sh
iii trigger compose::add worker=claude-code
iii   # starts the engine + worker
```

Then talk to it like any other function: from the console chat, from `iii trigger claude::run`, or from any SDK:

```ts
import { registerWorker } from 'iii-sdk';

const iii = registerWorker('ws://127.0.0.1:49134', { workerName: 'demo' });

const res = await iii.trigger({
  function_id: 'claude::run',
  payload: {
    prompt: 'Add a /health endpoint to server.ts and run the tests',
    cwd: '/path/to/repo',
    permission_mode: 'acceptEdits',
  },
  timeout_ms: 600_000,
});
// { session_id, claude_session_id, result, stop_reason, usage, total_cost_usd }
```

Or straight from the terminal with the `iii trigger` CLI:

```bash
# one full turn (raise the timeout; the default 30s is too short for agent turns)
iii trigger claude::run --timeout-ms 600000 \
  --json '{"prompt":"add a /health endpoint and run the tests","cwd":"/path/to/repo"}'

# quick reads use key=value syntax
iii trigger claude::sessions::list
iii trigger claude::status session_id=<session_id>

# background turn + control
iii trigger claude::start --json '{"prompt":"...","cwd":"/path/to/repo"}'
iii trigger claude::stop session_id=<session_id>

# ask the running engine for a function's description
iii trigger claude::run --help
```

A turn from the CLI and the session record it leaves behind:

![iii trigger claude::run returning the result with usage and cost](https://raw.githubusercontent.com/iii-hq/workers/main/claude-code/assets/cli-run.png)

![iii trigger claude::status showing the stored session record](https://raw.githubusercontent.com/iii-hq/workers/main/claude-code/assets/cli-status.png)

![iii trigger claude::run --help printing the published request schema as a parameter table](https://raw.githubusercontent.com/iii-hq/workers/main/claude-code/assets/cli-help.png)

Call `claude::run` again with the returned `session_id` to continue the same conversation: the worker maps iii session ids to Claude Code session ids in engine state and resumes automatically.

Two ids come back from every run. `session_id` is the iii session id: the key for `claude::status`, `claude::stop`, resume, and the `session_id` an event-feed binding filters on. `claude_session_id` is Claude Code's internal session id (what the worker passes to the CLI's resume under the hood) — returned for reference, not a lookup key.

Long turns: use `claude::start` to return immediately, then bind `claude::agent-event` with `{ session_id }` (see [Event feeds](#event-feeds)) for `message_complete`, `function_execution_start/end`, and `turn_end` frames. `claude::stop` interrupts a live run, `claude::status` reads a point-in-time view, `claude::sessions::list` enumerates past sessions.

## Functions

| Function | Purpose |
| --- | --- |
| `claude::run` | Run one turn, wait, return the final result |
| `claude::start` | Fire-and-forget turn; progress arrives on `claude::agent-event` |
| `claude::task` | Delegate one task as a SUB-AGENT: fire it from a trigger, get the session id back at once, pass `parent_session_id` to nest it under the session that delegated it, and read the outcome from `agent_tasks/<session id>` in state — which is what an orchestrator binds a `state` trigger to and is woken by |
| `claude::stop` | Interrupt a live run |
| `claude::status` | Session state, live flag, usage, cost |
| `claude::sessions::list` | All sessions this worker has run |
| `run::start_and_wait` | Alias for `claude::run` under the entrypoint the console and acp worker drive |
| `claude::terminal::describe` | What a terminal session runs: program, argv, cwd, env — the page passes it straight to `shell::pty::open` — plus `activity_bridge` and `detail`, which say whether the hooks can reach the bus. Internal. |
| `claude::terminal::activity` | One Claude Code hook event in, AgentEvent frames out. Internal, and `trace_hidden` — the signal is the stream, not the delivery. |
| `claude::auth::status` | Which plan a terminal session spends (see [Billing](#billing-which-plan-a-session-spends)). Agent-denied. |
| `claude::ui-content` | Console page assets. Internal. |

`claude::run` accepts either a bare `prompt` string or a `messages` array (`[{ role: 'user', content: [{ type: 'text', text }] }]`), plus `model`, `cwd`, `system_prompt`, `append_system_prompt`, `permission_mode`, `allowed_tools`, `disallowed_tools`, and `max_turns` overrides.

### Raw API pass-through

The named fields above cover the common path; everything else the Agent SDK accepts goes through the `options` field untouched (camelCase, exactly as in the SDK):

```jsonc
{
  "prompt": "...",
  "options": {
    "forkSession": true,
    "includePartialMessages": true,
    "fallbackModel": "claude-sonnet-4-6",
    "addDirs": ["/another/repo"]
  }
}
```

And the full output side is available raw: every message Claude Code emits (`system/init`, `assistant`, `user`, `result`, and `stream_event` token deltas when `includePartialMessages` is set) is published verbatim on the `claude::raw-event` trigger type. Consumers that want the exact Claude Code wire format bind `claude::raw-event`; consumers that want harness-shaped frames bind `claude::agent-event`. Same turn, two views.

## Event feeds

The worker owns two trigger types and publishes every turn on them (headless
runs, `claude::task` sub-agents, `run::start_and_wait`, and the console
terminal):

| Trigger type | `event` carries |
| --- | --- |
| `claude::agent-event` | translated AgentEvent frames (`message_complete`, `function_execution_start/end`, `turn_end`, `agent_end`) |
| `claude::raw-event` | every Claude Code message verbatim (`system/init`, `assistant`, `user`, `result`, `stream_event`) |

Bind a function to one session with the config `{ session_id, metadata? }`
(`session_id` required, non-empty, up to 512 characters; `metadata` is an
optional object handed to your function and wins over the binding's own
metadata; any other key rejects the binding). Each trigger type accepts up to
256 bindings.

```ts
iii.registerTrigger({
  type: 'claude::agent-event',
  function_id: 'my::on-claude-event',
  config: { session_id },
});
```

Your function receives one call per frame, fire-and-forget:

```jsonc
{
  "session_id": "sess_...",
  "event_id": "sess_...-<epoch>-00000007", // dedup key
  "seq": 7,              // contiguous per (trigger type, session_id, epoch), from 0
  "epoch": "<uuid>",     // changes when the worker restarts; seq restarts at 0
  "source": "claude",
  "event": { "type": "message_complete", "message": { } }
}
```

Deliveries are not guaranteed to arrive in order: order by `(epoch, seq)` and
drop duplicates by `event_id`. The two feeds number their frames
independently. The feeds are ephemeral: nothing is stored or replayed, and a
function bound after a frame was published never sees it. The durable history
is the session record (`claude::status`, `claude::sessions::list`), the
turn's return value, and the session-manager transcript (`session::messages`)
the console renders. A consumer that fails or disappears never fails the turn.

These replace the former iii-stream feeds `agent::events` and
`claude::events`; the worker no longer needs iii-stream (see the guide
"Migrate from iii-stream and pubsub"). The acp worker binds
`claude::agent-event` for the brain `claude::run`; when it drives
`run::start_and_wait` instead, start it with
`--events-trigger-type claude::agent-event`.

## The agent on the bus

By default every turn's system prompt carries the iii runtime context: the same engine-grounded rules as the harness identity prompts, retargeted to the `iii` CLI the agent reaches through its shell. The agent discovers capabilities from the live engine instead of memory — `iii trigger engine::functions::list` to find function ids, `iii trigger <fn> --help` as the contract before every first call, the registry flow (`directory::registry::workers::list/info`, `worker::add`) when nothing registered fits — plus the calling rules and error-handling discipline that go with them. The matching `Bash(iii *)` allow rule is added automatically so those calls run headless. Local file edits stay on Claude Code's native tools; backend actions go through registered functions.

```bash
# the agent answers this by querying the live engine itself
iii trigger claude::run --timeout-ms 300000 \
  --json '{"prompt":"List every worker connected to this engine and what each one does.","cwd":"/tmp"}'
```

Turn it off per call with `"iii_context": false` or globally in `config.yaml`; a caller-supplied `system_prompt` always wins verbatim and gets nothing appended.

## Plan mode and permission modes

`permission_mode` maps straight onto Claude Code's native modes, per turn:

| Mode | Behavior |
| --- | --- |
| `default` | Claude Code's standard permission prompts (headless: unapproved calls fail) |
| `acceptEdits` | File edits auto-approved; the worker default |
| `plan` | Native plan mode: read-only exploration, produces a plan, refuses edits |
| `bypassPermissions` | Skip all permission checks |

Plan mode headless behaves like plan mode in the terminal: the turn ends when Claude finishes the plan, and the plan text is the `result` — nothing executes. Because the worker resumes sessions, plan-then-execute is two calls against the same `session_id`:

```bash
# 1. plan (read-only)
iii trigger claude::run --timeout-ms 600000 \
  --json '{"prompt":"Plan how to add rate limiting to the REST API. Do not implement.","cwd":"/path/to/repo","permission_mode":"plan"}'

# 2. execute the plan with full context, same conversation
iii trigger claude::run --timeout-ms 600000 \
  --json '{"session_id":"<from-step-1>","prompt":"Implement the plan.","permission_mode":"acceptEdits","cwd":"/path/to/repo"}'
```

The approval step is whatever sits between the two calls — a human reading the plan, another worker, or a trigger.

## The terminal on the console

A **claude** page appears in the console nav; opening it starts a session. The
first run installs the CLI and the skills, so it takes a minute. Answer the
login prompt in the terminal, then ask Claude to build something on the engine
— the workspace notes and the installed iii skills teach it how to register
functions and triggers. Every prompt and tool call it runs is published on
`claude::agent-event`, exactly like a `claude::run` turn.

```bash
iii trigger claude::terminal::describe   # what a session runs, and where
iii trigger shell::pty::sessions         # what is actually running
iii trigger claude::status session_id=<session id>   # the session record
iii trigger session::messages session_id=<session id> # the persisted transcript
```

The page keeps a per-tab lease, so a reload or a pane move reattaches to the
same live Claude rather than starting a second one.

A session outlives the tab that opened it: the reattach lease is stored per
browser, so closing the console and coming back finds the same agent instead
of starting a second one beside it. When the replay would be partial — the
worker's ring buffer is finite and an agent repaints constantly — the page
skips the broken history and asks the agent to paint its current screen (a
one-row resize, which is a SIGWINCH) rather than writing wreckage into the
pane.

The status bar carries a font-size stepper (8–40 px, 14 by default; Ctrl or ⌘
+ scroll does the same). The size is one value for every terminal in the
console — shell's panes included — so it is set once, not per page. The
terminal itself stays dark whatever the console theme is: an agent CLI paints
its own interface in colors chosen for a dark terminal and never learns the
page went light.

### What the workspace gets

On every boot, when `terminal.setup_workspace` is on:

- `CLAUDE.md`, inside an `<!-- iii:begin -->` block the worker rewrites.
  Anything outside the markers is the operator's and survives. The iii half of
  that block — how to work against a live engine, and what skills are installed
  — is FETCHED from the [`iii-directory`](https://github.com/iii-hq/workers/tree/main/iii-directory)
  worker (`directory::system-prompts::get name=iii-runtime` plus
  `directory::skills::index`), which is also what a headless turn is given. One
  copy, one owner: this worker ships neither the prompt nor a second set of
  skills on disk. When the directory answers nothing, the block says so and
  points the agent at `engine::functions::list`.
- `.iii-plugin/`, a Claude Code **plugin** the session loads with
  `--plugin-dir`. It carries the hooks for `SessionStart`, `SessionEnd`,
  `UserPromptSubmit`, `PreToolUse`, `PostToolUse` and `Stop` — each posting its
  payload to `claude::terminal::activity` with the `iii` CLI, so it works
  whether or not the terminal host is this worker's host — and the
  `iii-runtime` skill fetched from `iii-directory`.

  The same plugin serves the headless half: the Agent SDK turns
  `plugins: [{ type: 'local', path }]` into that same `--plugin-dir` flag, so a
  typed turn and a `claude::run` turn get identical hooks and the identical
  skill from one definition (`src/plugin.ts`). `claude plugin validate` accepts
  the generated directory.

  `.claude/settings.json` is no longer written at all. It is the operator's
  file; the plugin directory is this worker's, so it is written whole instead of
  merged into.

## Billing: which plan a session spends

The status bar under the terminal says it, and `claude::auth::status` answers
it on the bus:

| Badge | Means |
|---|---|
| `team subscription · you@example.com · org` | A Claude subscription login on the terminal host pays. |
| `API key billing · ANTHROPIC_API_KEY` | An API key pays, per token. |
| `not signed in` | No credentials yet — run `/login` in the terminal. |

The rule has a trap in it, which is why the badge exists: **an API key beats a
subscription login.** With `ANTHROPIC_API_KEY` set, `claude auth status` still
reports `authMethod: claude.ai` (the account IS signed in) while the CLI bills
the key — it says so itself ("ANTHROPIC_API_KEY or another auth source is set
and takes precedence over your claude.ai login") and a bogus key 401s the turn.
So the badge reads `apiKeySource`, not `authMethod`, and the tooltip spells out
that the login is signed in but not billed.

The key is read from the environment of the `shell` worker that runs the
session, so that is where to set or unset it.

**A subscription login needs `USER` in the session environment.** Claude Code
keeps subscription credentials in the OS keychain and finds them by the
current user, and a worker's environment is not the operator's shell — compose
clears it and re-seeds an allowlist from the daemon's own environment, so a
daemon started without `USER` hands every child a blank one. The CLI then
reports `loggedIn: false` beside a keychain that plainly holds the login. This
worker asks the terminal host who it is (`id -un`) and puts `USER`/`LOGNAME`
into every session and into the billing probe, so the answer does not depend
on how the daemon was started.

`claude::auth::status` stays agent-denied in `iii-permissions.yaml`: it carries
the operator's account and organization. The console page reaches it as a
user-initiated call, which is not the agent path.

## Logging in

The simplest flow is the terminal itself: open the page and run `/login` (or
`claude auth login`; `--console` for API billing instead of the subscription).
The credentials land in the home directory of whatever runs the session — the
`shell` worker's — and every later session of this terminal reuses them.

**Who else sees that login depends on how a worker is deployed, not on which
machine it is.** Compose starts a container one of two ways
(`iii-compose/src/lifecycle.rs`):

| Container | Start | Home directory |
|---|---|---|
| `path://…` (local checkout), or a registry worker whose payload is a **binary** (Rust `deploy: binary`, e.g. `shell`) | host process | the host's |
| a registry worker whose payload is a **bundle** (Node `deploy: bundle`, e.g. this worker) | microVM, own rootfs | the guest's |

Only the container's own config directory is shared into a VM (virtiofs), and
compose v1 has no volume field, so a VM-booted worker cannot read the host's
`~/.claude`. Consequences:

- The terminal half is unaffected either way: the CLI runs inside the `shell`
  worker, which is a host process, so the login is the host's and this
  worker's own rootfs never matters.
- The headless half spawns `claude` in **this** worker's container. Run from a
  local checkout it is a host process and shares that same login; installed
  from the registry it is in a microVM and does **not**. Give it credentials
  through the channels a VM has: `environment` / `env_file` on its container
  (`ANTHROPIC_API_KEY`, or a long-lived subscription token from `claude
  setup-token`), or the `auth-credentials` vault over the bus — which is why
  that vault exists, and why `provider-claude-code` reads it instead of the
  file.

### If the worker that owns the terminal is virtualized

The terminal half holds because `shell` is a binary payload and therefore a
host process today. If binary workers get VM-booted too, the terminal keeps
working — the CLI, the workspace, and the login simply move into that guest,
and this worker still reaches them over the bus — but three things change:

- The login belongs to that guest's rootfs. Nothing on the host shares it, and
  it survives restarts only as long as the guest's state does.
- The workspace is inside the guest, so the agent no longer sees the
  operator's checkout unless the guest is given it.
- The activity hooks call the `iii` CLI, which may not exist in that guest.
  The worker probes for it and reports the answer as `activity_bridge` on
  `claude::terminal::describe` (empty = the hooks are installed but mute, and
  `detail` says so). That is the first thing to check if a terminal works
  while nothing arrives on `claude::agent-event`.

For a terminal host with no one at the keyboard, the same two options apply:
`claude setup-token` once (needs a browser once, returns a long-lived
subscription token) or `ANTHROPIC_API_KEY`, in the environment the `shell`
worker starts with. Either way the badge says which one won.

## Configuration

```yaml
engine_url: ws://127.0.0.1:49134

defaults:
  model: ""                    # empty = Claude Code default
  permission_mode: acceptEdits # default | acceptEdits | plan | bypassPermissions
  max_turns: 50
  cwd: ""                      # default working directory for runs

approval_gate: false           # route tool permissions through policy::check_permissions
claude_executable: ""          # path to the claude CLI; empty = SDK default resolution

terminal:                      # the console terminal page — a DIFFERENT host: shell's
  executable: ""               # path to `claude` there; empty = resolve on PATH there
  args: []                     # extra argv for every terminal session
  workspace_dir: ""            # empty = `claude-code` under shell's primary root
  auto_install: true           # install Claude Code from https://claude.ai/install.sh when missing
  setup_workspace: true        # keep the skills, the engine notes, and the activity hooks in place
```

Settings live in the `configuration` worker under the **Claude Code** entry —
edit them in the Console's global Settings modal; they hot-reload, and a change to the
`terminal` block re-runs the workspace preparation for the next session.

The event feeds are fixed trigger types, not settings. The former
`events_stream` / `raw_events_stream` keys are ignored; a stored
configuration that still carries them keeps loading.

With `approval_gate: true` and the harness worker installed, every Claude Code tool call is checked against `policy::check_permissions` before it executes, fail-closed when the gate is unreachable, so the same YAML permission rules and console approval flow that govern native harness turns govern Claude Code.

## Observability

Every `claude::run` is an ordinary traced invocation on the engine: the trace carries the full input payload (prompt, cwd, caller worker id) and the output (result, stop reason, token usage, cost) as span events, with per-function p50/p95/p99 in the console's trace explorer — no extra instrumentation in the worker.

![claude::run invocations in the iii console trace explorer, with input and output payloads](https://raw.githubusercontent.com/iii-hq/workers/main/claude-code/assets/console-traces.png)

### Turns group like a harness turn

Both halves stamp the identity keys an agent harness stamps, so the trace views
group and label this worker's turns with no knowledge of Claude Code
(`ade/docs/timeline-span-tags.md`):

| Key | Value here |
| --- | --- |
| `iii.session.id` | the iii session — what "group by session" groups on |
| `iii.message.id` | one turn: one headless `claude::run`, or one terminal prompt |
| `iii.tag.kind` | `claude.run` for a headless turn, `claude.terminal.turn` for a typed one |
| `iii.tag.message` | a preview of the prompt |
| `iii.tag.display_name` | `Claude terminal · <prompt>` on the terminal half |

The transport is W3C baggage, so every call a turn makes — and every span those
calls produce in other workers — carries the same keys. A terminal turn reaches
this worker as several separate calls (one per hook), so its identity lives in
those keys rather than in one covering span; a headless turn gets both.

## How it maps

| Claude Code | iii |
| --- | --- |
| SDK `query()` turn | `claude::run` invocation |
| every SDK message, verbatim | `claude::raw-event` delivery |
| assistant message | `message_complete` frame on `claude::agent-event` |
| tool_use / tool_result | `function_execution_start` / `function_execution_end` frames |
| final result | `turn_end` + `agent_end` frames, function return value |
| session resume | engine state scope `claude_sessions`, keyed by iii session_id |
| permission prompt | `canUseTool` -> `policy::check_permissions` (optional) |
| extra capability | another iii worker on the bus (`shell`, `database`, `storage`, ...) |
| interactive CLI session | `shell::pty` session on the injected **claude** console page |
| terminal hook event | `claude::terminal::activity` -> the same `claude::agent-event` frames |
