---
url: 'https://adk.nht.io/batteries/llm/claude_code_cli.md'
description: >-
  Wraps the real `claude` binary as a DispatchExecutorFn: a standalone wrapper
  subprocess spawns the CLI in stream-json mode and bridges your ADK tools back
  to it over a local MCP server, since the CLI has no client-callable-tool API
  of its own.
---

# Claude Code CLI

## LLM summary — Claude Code CLI battery

* [`ClaudeCodeCliAdapter`](https://adk.nht.io/api/@nhtio/adk/batteries/llm/claude_code_cli/adapter/classes/ClaudeCodeCliAdapter) from `@nhtio/adk/batteries/llm/claude_code_cli`. Drives the real `claude` binary as a subprocess — not an HTTP API. Spawns a standalone wrapper process (`resolveDefaultWrapperPath()`, always invoked as `execa(process.execPath, [wrapperPath], { cleanup: true })` — built output has no executable bit, so `execa(wrapperPath, [])` throws `EACCES`) which itself spawns `claude --bare -p --verbose --output-format stream-json ...` and hosts a local MCP HTTP bridge on an ephemeral loopback port.
* Required: `model`. Auth: exactly one of `apiKey` (→ `ANTHROPIC_API_KEY`) or `authToken` (→ `ANTHROPIC_AUTH_TOKEN`) — XOR-validated, both empirically confirmed working under `--bare`. `baseURL` → `ANTHROPIC_BASE_URL`, valid with either. Third-party providers (Bedrock/Vertex/Foundry) are out of scope in v1, no escape hatch.
* Built-in CLI tools are fully disabled (`--tools ""`); your ADK tools are bridged over a local `Server` (low-level MCP SDK, not `McpServer` — ADK tools are Joi-shaped, not Zod) advertised via `--mcp-config`. `--dangerously-skip-permissions` is required to let the bridge's tools run non-interactively — this removes the CLI's permission engine entirely, so `--allowedTools` is emitted only as a human-readable statement of intent, NOT the enforcement mechanism. Real enforcement is the bridge itself, twice over: `disallowedTools` filters the adapter's tool list before the wrapper/bridge ever sees it, and the bridge's own `CallTool` handler re-checks the name against that same filtered set.
* No `contextWindow` guard (the CLI manages its own context internally with no client-side visibility). CLI-native caps substitute: `maxBudgetUsd` (always available, → `--max-budget-usd`) and `maxTurns` (capability-probed once per adapter instance via `claude --help`, → `--max-turns` only when the installed CLI actually supports the flag). Exhaustion of either surfaces as a terminal `result.isError` mapped to `E_CLAUDE_CODE_CLI_TURN_FAILED`.
* v1 gotchas: prompt is text-only (`-p` has no image side-channel — attachments render through `unsupportedMediaPolicy` as text); subagent text is invisible unless `forwardSubagentText: true`; no dispatch-level retry (`system/api_retry` is `helpers.log.warn` observability only, not retried); process-group SIGTERM cleanup of the `claude` grandchild is real but an uncatchable SIGKILL of the wrapper itself is a documented, accepted v1 gap (an orphaned grandchild has no tools to call, since `--tools ""` already disabled its built-ins).
* `extraArgs` is a strict allowlist escape hatch (`--effort`/`--agent`/`--betas`/`--json-schema`/`--name`/`--prompt-suggestions`) — never a permission/tool/MCP/session-state flag, and every value string is rejected if it starts with `-` (closes a live-demonstrated argv-injection path via `--betas`'s variadic value slot).
* `STASH_KEY` `claudeCodeCli`. Exceptions: `E_INVALID_CLAUDE_CODE_CLI_OPTIONS`, `E_CLAUDE_CODE_CLI_{BINARY_NOT_FOUND,WRAPPER_SPAWN_ERROR,WRAPPER_CRASHED,PROCESS_EXITED_NONZERO,STREAM_ERROR,STREAM_STALLED,STARTUP_TIMEOUT,MCP_BRIDGE_STARTUP_FAILED,TURN_FAILED,UNSUPPORTED_MEDIA_MODALITY}`. POSIX-only in v1 (rejected at construction on non-POSIX platforms).

Every other LLM battery in this project talks to a model over HTTP. This one doesn't — there is no Claude Code
HTTP API. What exists is a CLI binary that owns its own agent loop, its own context management, and its own
tool-execution story, and the only way to make it run one turn on ADK's behalf, with ADK's tools instead of its
own, is to drive the actual `claude` process.

```ts
import { ClaudeCodeCliAdapter } from '@nhtio/adk/batteries/llm/claude_code_cli'

const executor = new ClaudeCodeCliAdapter({
  model: 'claude-sonnet-5-20260701',
  apiKey: process.env.ANTHROPIC_API_KEY,
  autoAck: true,
}).executor()
```

That constructs the adapter and satisfies [`DispatchExecutorFn`](https://adk.nht.io/api/@nhtio/adk/dispatch_runner/type-aliases/DispatchExecutorFn) in the same one-line shape every other
battery does. Everything below is what happens behind that line, because a CLI harness earns its keep by being
honest about the machinery, not by pretending it's an HTTP call.

## Why a wrapper process

`ClaudeCodeCliAdapter` never spawns `claude` directly. It spawns a small, standalone Node script — the
**wrapper** — and the wrapper spawns `claude`. Three things force that extra hop:

* **The MCP bridge has to exist somewhere with a lifetime independent of any one ADK dispatch iteration's
  event loop.** `claude` calls back into ADK tools over MCP; something has to host that server, and it has to
  be reachable by `claude` as a subprocess, not as an in-process object.
* **Claude's own stream-json needs translating before it's ADK-shaped.** The wrapper reads `claude`'s NDJSON
  stream, converts each line into a normalized `WrapperEvent`, and is the only place that needs to know
  Claude's specific vocabulary (`system/init`, `assistant`, `stream_event`, `result`, …). The adapter, on the
  other side of a small zero-import wire protocol, never parses a Claude-specific shape at all.
* **Process-group lifecycle discipline.** `claude` is spawned by the wrapper detached, in its own process
  group, so a `SIGTERM` to the wrapper can `process.kill(-pid, 'SIGTERM')` the whole group — killing `claude`
  and anything it spawned. Doing that gymnastics from inside the adapter's own process, without a wrapper
  boundary, buys nothing and complicates everything else the adapter does.

The wrapper ships as a compiled sibling asset — `dist/claude-code-cli-wrapper.mjs`/`.cjs` at the package root,
next to `dist/adk-mcp.mjs` — not as something you import. `resolveDefaultWrapperPath()` finds it relative to
the adapter's own compiled location and is exposed as an overridable `wrapperPath` option. It is always spawned
as `execa(process.execPath, [wrapperPath], { cleanup: true })`, **never** `execa(wrapperPath, [])` — Vite's
build output carries a `#!/usr/bin/env node` shebang but is not actually executable on disk (confirmed
directly: the compiled files are mode `-rw-r--r--`), so invoking the file by path alone throws `EACCES`.
Invoking the installed Node binary explicitly, with the wrapper file as its first argument, is the only form
that works regardless of how the package was installed.

`execa`'s own `cancelSignal` is deliberately not used — it fires `SIGTERM` immediately on abort, which would
race the adapter's own graceful-shutdown sequence (reject in-flight tool calls → close the MCP transport → close
the HTTP listener → wait for `claude` to actually exit → only then escalate to a signal). `cleanup: true` is
kept as a backstop against the adapter's *own* process dying unexpectedly and orphaning the wrapper — an
independent safety net, not a substitute for the hand-rolled sequence.

## The MCP bridge, and why the bridge — not `--allowedTools` — is the real gate

`claude` is invoked with its built-in tools fully disabled (`--tools ""`) and your ADK tools advertised instead
through a local MCP server the wrapper hosts on an ephemeral loopback port, wired in via `--mcp-config`. That
server is built on the MCP SDK's low-level `Server` class, not `McpServer.registerTool()` — ADK tools carry
Joi-shaped schemas, already rendered to plain JSON Schema by the adapter, and the low-level `Server` is the
form that takes protocol schemas (`ListTools`/`CallTool`) rather than demanding a Zod object per tool.

Running non-interactively also requires `--dangerously-skip-permissions`, and that flag does something more
drastic than its name suggests: it removes Claude Code's entire permission engine, not just the interactive
prompt. Once the permission engine is gone, there is no decision point left for an allow-rule to influence —
which means `--allowedTools` (still emitted, comma-joined, `mcp__adk_bridge__`-prefixed per bridged tool name)
is **not** what stops a disallowed tool from running. It's kept purely as a defense-in-depth, human-readable
statement of intent visible in the invocation itself.

The real enforcement happens in the bridge, twice over:

1. **Before the wrapper ever sees a tool name.** The adapter filters your `disallowedTools` out of the tool
   list *before* it hands the remainder to the wrapper as `bridgedTools`. A disallowed tool is never even
   listed by the bridge's `ListTools` handler.
2. **On every `CallTool`, independently.** The bridge's `CallTool` handler re-checks the requested name against
   that same already-filtered list before ever forwarding it to the adapter as a `tool_call_request`. This is
   deliberately redundant with step 1 — a defense against the pre-filter having a bug, not an assumption that
   it doesn't.

There is no permission tool anywhere in this design, and no permission-mode option on the adapter. Permissions
are bypassed wholesale on the CLI side; the bridge is where the actual gate lives.

## Auth

Exactly one of `apiKey` (`ANTHROPIC_API_KEY`) or `authToken` (`ANTHROPIC_AUTH_TOKEN`) must be set — validated
at construction and re-validated every dispatch iteration, never both, never neither. `baseURL`
(`ANTHROPIC_BASE_URL`) is valid alongside either. All three are set on the `claude` grandchild's environment by
explicit delete-then-set: the wrapper starts from a full copy of its own `process.env` (so `PATH`/`HOME`/etc.
still reach `claude`), deletes any ambient `ANTHROPIC_API_KEY`/`ANTHROPIC_AUTH_TOKEN`/`ANTHROPIC_BASE_URL`, then
sets exactly what the current dispatch's options specify. An inherited ambient credential can never silently
coexist with, or substitute for, the one the options actually name.

`claude --bare --help` describes its auth as strictly `ANTHROPIC_API_KEY` or an `apiKeyHelper` and says OAuth
and keychain credentials are never read under `--bare`. That reads as if `ANTHROPIC_AUTH_TOKEN` were excluded
too — it isn't. A live call against a real gateway with only `ANTHROPIC_AUTH_TOKEN` set (no `ANTHROPIC_API_KEY`
present at all) returned a genuine, billed, successful generation. `--bare`'s text is describing the exclusion
of *interactive* credential discovery, not rejecting the auth-token env var. Both mechanisms work.

Third-party provider credentials — Bedrock, Vertex, Foundry — are genuinely unsupported in v1. There is no
`extraArgs` entry, no env-passthrough option, and no other escape hatch for them; this is a stated scope
boundary, not an oversight.

## No `contextWindow` — CLI-native caps instead

Every other LLM battery in this project accepts a `contextWindow` and throws once client-side accounting
crosses it. This one doesn't, on purpose: Claude Code manages its own context internally (its own compaction,
its own token accounting) with zero visibility from outside the process. There is nothing for a client-side
guard to measure.

Two CLI-native caps substitute:

* **`maxBudgetUsd`** → `--max-budget-usd`, always available.
* **`maxTurns`** → `--max-turns`, emitted only when a one-time capability probe (`execa(claudeBin, ['--help'])`,
  cached per adapter instance, never repeated per dispatch) confirms the installed CLI actually has the flag.
  Older CLI builds that lack it silently fall back to budget-only capping — the option is never a hard version
  requirement.

Either cap's exhaustion surfaces the same way: a terminal `result` with `isError: true`, which the adapter maps
to `ctx.nack(new E_CLAUDE_CODE_CLI_TURN_FAILED(...))` — the same *shape* of outcome as another battery's
context-overflow exception, just triggered by the CLI's own bookkeeping instead of a client-side estimate.

## The gotchas, because they will get you

**The prompt is text-only in v1.** A `-p` positional string has no native image side-channel the way Ollama's
`images[]` array does. Every attachment on a rendered timeline message — image or otherwise — routes through
`unsupportedMediaPolicy` and renders as text. If you need genuine multimodal input, this is not (yet) the
battery for it.

**Subagent text is invisible by default.** Claude Code's own subagents produce `assistant` text tagged with a
`parent_tool_use_id`; the adapter drops it unless you set `forwardSubagentText: true`. The main conversation's
text always surfaces regardless.

**There is no dispatch-level retry.** A `system/api_retry` event from the CLI is observability only — it flows
to `helpers.log.warn` and nothing else. A transient failure inside the CLI's own turn is the CLI's own retry
concern; the adapter does not layer a second retry loop on top of it.

**`--max-turns` is conditional, not configuration you can force on.** See the capability-probing note above —
if the installed `claude` binary predates the flag, setting `maxTurns` has no effect (and no error; it's simply
omitted from argv).

**Process-group cleanup is real, but not airtight.** The wrapper spawns `claude` detached in its own process
group specifically so a `SIGTERM` to the wrapper can kill the whole group via `process.kill(-pid, 'SIGTERM')`.
That path is exercised end-to-end by this battery's tests, including the case where a tool call is in flight
and an MCP SSE stream is open when the signal arrives. What it does **not** cover is an uncatchable `SIGKILL`
delivered straight to the wrapper — no process can intercept that signal to run its own cleanup, so a
`SIGKILL`'d wrapper can leave the `claude` grandchild running. This is accepted as a known v1 limitation rather
than silently claimed to be solved: an orphaned grandchild has no ADK tools available to it (its built-ins were
already disabled via `--tools ""`), so the leftover process is a low-severity resource leak, not a security or
correctness hole.

**POSIX only.** The detached-process-group kill relies on `process.kill(-pid, signal)`, which has no Windows
equivalent. The adapter checks `process.platform` at options-validation time and throws
`E_INVALID_CLAUDE_CODE_CLI_OPTIONS` on non-POSIX platforms rather than attempting a degraded, partially-working
fallback.

**The wrapper is always spawned via `process.execPath`.** Covered above under "Why a wrapper process" — worth
repeating here because it's the detail that bites if you ever try to invoke `wrapperPath` directly yourself.

**`extraArgs` is a narrow, validated escape hatch, not a general passthrough.** Only six flags are ever
accepted (`--effort`, `--agent`, `--betas`, `--json-schema`, `--name`, `--prompt-suggestions`), none of them
capable of touching tool, permission, MCP, or session-state configuration. Every value string in every position
is rejected if it starts with `-` — this closes a concretely demonstrated injection where a variadic value
(e.g. an extra element in a `--betas` array) could smuggle in an entirely different flag (`--model
attacker-chosen`) ahead of the trailing `--` separator.

## Exceptions

| Exception | When |
| :--- | :--- |
| `E_INVALID_CLAUDE_CODE_CLI_OPTIONS` | Resolved options (constructor, executor override, or `stash.claudeCodeCli`) fail validation — includes the `apiKey`/`authToken` XOR violation, a rejected `extraArgs` entry, and the POSIX-only platform guard. |
| `E_CLAUDE_CODE_CLI_BINARY_NOT_FOUND` | The `claude` binary named by `claudeBin` cannot be located/spawned. |
| `E_CLAUDE_CODE_CLI_WRAPPER_SPAWN_ERROR` | The wrapper process itself fails to spawn. |
| `E_CLAUDE_CODE_CLI_WRAPPER_CRASHED` | The wrapper process crashes or exits abnormally mid-dispatch. |
| `E_CLAUDE_CODE_CLI_PROCESS_EXITED_NONZERO` | The wrapper exits with no terminal `result`/`error` event ever observed. |
| `E_CLAUDE_CODE_CLI_STREAM_ERROR` | A transport-level failure on the adapter↔wrapper stream. |
| `E_CLAUDE_CODE_CLI_STREAM_STALLED` | The wrapper's stdout goes idle past `streamIdleTimeoutMs` after startup has completed. |
| `E_CLAUDE_CODE_CLI_STARTUP_TIMEOUT` | The wrapper's `ready` event and Claude's `system/init` don't both arrive within `startupTimeoutMs`. |
| `E_CLAUDE_CODE_CLI_MCP_BRIDGE_STARTUP_FAILED` | The wrapper's own MCP bridge fails to start, Claude's `system/init` reports an `mcp_server_errors` entry naming it, or the `@modelcontextprotocol/sdk` optional peer is missing. |
| `E_CLAUDE_CODE_CLI_TURN_FAILED` | Claude's terminal `result` reports `isError: true` — includes `maxBudgetUsd`/`maxTurns` exhaustion. |
| `E_CLAUDE_CODE_CLI_UNSUPPORTED_MEDIA_MODALITY` | A `Media` whose modality can't be represented reaches either direction — inbound prompt media (governed by `unsupportedMediaPolicy`) or outbound tool-result media (governed by the separate `unsupportedResultMediaPolicy`) — under a `'throw'` policy. |

A malformed NDJSON frame, on either side of the wire, is explicitly **not** an exception — it's swallowed and
surfaced via `helpers.log.trace`, matching the Ollama battery's own malformed-line policy.

Full option surface in [Assembly → LLM batteries](/assembly/batteries-llm).
