Claude Code CLI
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.
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.
claudecalls back into ADK tools over MCP; something has to host that server, and it has to be reachable byclaudeas 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 normalizedWrapperEvent, 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.
claudeis spawned by the wrapper detached, in its own process group, so aSIGTERMto the wrapper canprocess.kill(-pid, 'SIGTERM')the whole group — killingclaudeand 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:
- Before the wrapper ever sees a tool name. The adapter filters your
disallowedToolsout of the tool list before it hands the remainder to the wrapper asbridgedTools. A disallowed tool is never even listed by the bridge'sListToolshandler. - On every
CallTool, independently. The bridge'sCallToolhandler re-checks the requested name against that same already-filtered list before ever forwarding it to the adapter as atool_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.