Skip to content
11 min read · 2,231 words

Claude Code CLI ​

Every other LLM battery in this project talks to a model over HTTP, and so, underneath, does this one — the claude binary reaches Claude through Anthropic's ordinary HTTP API. What has no HTTP endpoint is Claude Code itself: there is nothing you can POST to and get the CLI's agent loop, its context management, and its tool-execution story. Those live in the binary.

Nor can you get them by imitating it. Anthropic constructs the CLI's requests in a way it fingerprints server-side, deliberately and specifically so that a subscription entitlement cannot be driven from an unapproved client. That is a product boundary, not an oversight, and it is not something a battery should try to route around. So when you want Claude Code's behaviour — not merely Claude's — the supported path is the one this battery takes: drive the actual claude process, and hand it ADK's tools instead of its own.

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 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.

Context-window guard and fixed single-turn dispatch ​

When both contextWindow and a non-null tokenEncoding are configured, the adapter performs a pre-flight estimate after building the prompt and before spawning the wrapper. It measures the rendered -p prompt string directly, adds appendSystemPrompt when configured, and adds the serialized MCP tool declarations (the tools are delivered over MCP, not in the prompt). If the total exceeds contextWindow, it throws E_CLAUDE_CODE_CLI_CONTEXT_OVERFLOW before the wrapper is spawned. The debug record and exception retain per-source-bucket diagnostics for middleware deciding what to shed, but those diagnostics are not added a second time because their content is already represented in the rendered prompt. A non-null tokenEncoding without contextWindow is invalid.

The estimate is exact for the prompt and append-system content this adapter renders and forwards. It remains an honest floor only for Claude Code content the adapter cannot see: the Agent SDK preamble, CWD:/Date:, an x-anthropic-billing-header block, built-in tool schemas, and the MCP declaration envelope. Those vendor- injected tokens can still make Claude reject a request that passes this guard.

maxBudgetUsd is independent and remains a genuine cost cap, forwarded as --max-budget-usd.

Fixed single-turn dispatch ​

The wrapper always appends the fixed argv pair --max-turns 1. This is what makes the battery an executor: one dispatch permits one generation and all of its tool calls, then returns control to TurnRunner instead of letting Claude start another generation. There is no capability probe. The adapter always sends the flag and relies on the CLI itself as authority; a binary that lacks it exits non-zero and its stderr is surfaced as E_CLAUDE_CODE_CLI_PROCESS_EXITED_NONZERO rather than silently running uncapped.

maxTurns remains in the public options surface for compatibility, but is deprecated and inert: only maxTurns: 1 validates, and the value is ignored because argv always carries --max-turns 1. When Claude reaches that fixed boundary, its terminal result has subtype: error_max_turns and isError: true; this is the normal successful completion signal and is routed through the success path. Other error subtypes, such as budget exhaustion, remain failures reported as E_CLAUDE_CODE_CLI_TURN_FAILED.

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 1 is fixed argv, not an optional setting. There is no help-text capability probe: the CLI itself validates the flag. A binary that lacks it exits non-zero, with stderr surfaced as E_CLAUDE_CODE_CLI_PROCESS_EXITED_NONZERO, rather than being silently run uncapped. maxTurns is retained only as a deprecated, 1-only, inert compatibility option.

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 ​

ExceptionWhen
E_INVALID_CLAUDE_CODE_CLI_OPTIONSResolved 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_FOUNDThe claude binary named by claudeBin cannot be located/spawned.
E_CLAUDE_CODE_CLI_WRAPPER_SPAWN_ERRORThe wrapper process itself fails to spawn.
E_CLAUDE_CODE_CLI_WRAPPER_CRASHEDThe wrapper process crashes or exits abnormally mid-dispatch.
E_CLAUDE_CODE_CLI_PROCESS_EXITED_NONZEROThe wrapper exits with no terminal result/error event ever observed.
E_CLAUDE_CODE_CLI_STREAM_ERRORA transport-level failure on the adapter↔wrapper stream.
E_CLAUDE_CODE_CLI_STREAM_STALLEDThe wrapper's stdout goes idle past streamIdleTimeoutMs after startup has completed.
E_CLAUDE_CODE_CLI_STARTUP_TIMEOUTThe wrapper's ready event and Claude's system/init don't both arrive within startupTimeoutMs.
E_CLAUDE_CODE_CLI_MCP_BRIDGE_STARTUP_FAILEDThe 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_CONTEXT_OVERFLOWThe pre-flight token estimate exceeds contextWindow under the configured tokenEncoding; the wrapper has not been spawned. The estimate is an honest floor because Claude-injected prompt and MCP envelope tokens are invisible.
E_CLAUDE_CODE_CLI_TURN_FAILEDClaude's terminal result reports an error subtype other than normal error_max_turns completion — notably budget exhaustion.
E_CLAUDE_CODE_CLI_UNSUPPORTED_MEDIA_MODALITYA 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.