ACP bridge that drives Claude Code, codex, and opencode CLIs through one canonical wire.
@kodizm/acp is the Kodizm runtime's Agent Client Protocol bridge. It speaks one canonical JSON-RPC surface to the orchestrator, then translates each turn down to whichever CLI backend the session was opened against. The orchestrator never branches on backend; the same session/new, session/prompt, and sessionUpdate shapes carry every feature across all three.
Three CLIs are supported today: Claude Code (via @anthropic-ai/claude-agent-sdk), codex (via codex app-server subprocess), and opencode (via createOpencodeServer from @opencode-ai/sdk).
+------------------+ JSON-RPC over +------------------+ native protocol +-----------+
| Orchestrator | <----- NDJSON ----> | AcpServer | <-------------------> | Backend |
| (Kodizm core) | (stdio/pipe) | + BackendDriver | (SDK / subproc) | CLI |
+------------------+ +------------------+ +-----------+
KODIZM_BACKENDselects the driver at process boot. One process per backend.AcpServervalidates every inbound request against the canonical schema, then routes to theBackendDriverinterface.- Each driver maps the canonical request to its CLI's native shape. Stream events flow back through
emit.send()and surface assessionUpdatenotifications on the wire.
The driver contract is a single TypeScript interface with seven methods. The server never imports any concrete driver. New backends extend the registry; the wire layer does not change.
| Feature | Claude | codex | opencode |
|---|---|---|---|
session/new, session/prompt, session/cancel |
yes | yes | yes |
session/load (resume) |
yes | yes | yes |
session/fork |
yes | yes | yes |
session/compact (manual) |
yes | yes | yes |
| Image content blocks | yes | yes | yes |
Token + cost rollup (usage event) |
yes | yes | yes |
additionalDirectories (sandbox) |
yes | yes | n/a |
mcpServers injection |
yes | yes | yes |
systemPrompt replace + append |
yes | yes | yes |
skills pre-load |
yes | n/a | n/a |
Permissions (permission_request) |
yes | yes | yes |
askUserQuestion |
yes | yes | yes |
| Subagent events | yes | yes | yes |
| Thinking events | yes | yes | yes |
| Cross-process Pattern B resume | yes | yes | yes |
| Debug capture | yes | yes | yes |
Note
The published bin (kodizm-acp from dist/index.js) currently wires only KODIZM_BACKEND=claude. Codex and opencode drivers are fully implemented and tested, but reaching them today requires programmatic embedding (see API reference).
bun add @kodizm/acpRequires Bun >= 1.1.0. The codex and opencode CLIs must be installed separately when you use those backends.
The bin runs over stdio:
echo '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":1,"clientCapabilities":{}}}' \
| KODIZM_BACKEND=claude \
CLAUDE_CODE_OAUTH_TOKEN="sk-ant-oat01-..." \
CLAUDE_CODE_REMOTE=1 \
bunx @kodizm/acpReplace the credential pair with ANTHROPIC_API_KEY=... for the api-key path.
| Variable | Required | Description |
|---|---|---|
KODIZM_BACKEND |
yes | claude / codex / opencode |
CLAUDE_CODE_OAUTH_TOKEN + CLAUDE_CODE_REMOTE=1 |
claude (sub) | Subscription auth |
ANTHROPIC_API_KEY |
claude (api) | API key auth |
CLAUDE_CODE_PATH |
optional | Path to claude binary (default /usr/local/bin/claude) |
OPENAI_API_KEY or CODEX_API_KEY |
codex (api) | api-key path. Without it, codex falls back to chatgpt-mode auth in ~/.codex/auth.json |
OPENCODE_AUTH_CONTENT |
opencode (env) | JSON keyed by providerID. Layered onto subprocess env for the bridge lifetime only. Without it, opencode reads ~/.local/share/opencode/auth.json |
KODIZM_LOG_LEVEL |
optional | debug / info / warn / error. Default info |
KODIZM_DEBUG |
optional | 1 enables process-wide debug capture |
KODIZM_DEBUG_DIR |
optional | Forensic JSONL dir, default /tmp/kodizm-debug |
KODIZM_DEBUG_RAW_SECRETS |
incident-only | 1 disables redaction. Never set in production |
KODIZM_ACP_FORWARD_STDERR |
optional | 1 tees codex subprocess stderr to parent stderr |
Stdout is reserved for ACP frames. Logs go to stderr.
type NewSessionRequest = {
cwd: string // absolute path
mcpServers: McpServer[]
additionalDirectories?: string[] // absolute paths
systemPrompt?: string | { append: string }
model?: string // e.g. 'claude-haiku-4-5-20251001'
skills?: string[] // claude only
toolPolicy?: ToolPolicy
autoCompact?: boolean
permissionTimeoutMs?: number // mutually exclusive with permissionDeferTimeoutMs
permissionDeferTimeoutMs?: number
debug?: boolean
debugCaptureRawSdk?: boolean
debugCaptureRpc?: boolean
heartbeatIntervalMs?: number
inactivityThresholdMs?: number
settingSources?: ('user' | 'project' | 'local')[] // claude only; opt-out
_meta?: Record<string, unknown> // passthrough; canonical fields rejected
}Warning
permissionTimeoutMs and permissionDeferTimeoutMs are mutually exclusive. Pick hard-deny on timeout OR soft-defer on timeout, not both. The schema rejects the conflict with a clear error.
Note
settingSources is the claude-only opt-out for filesystem config layering. When omitted the SDK's own default fires, matching the standalone Claude Code CLI: project CLAUDE.md + .claude/CLAUDE.md + .claude/rules/*.md load from cwd walking up; user ~/.claude/CLAUDE.md + ~/.claude/rules/*.md and CLAUDE.local.md load too. Pass [] to disable every fs scope; pass a selective subset like ['project'] to load only project-tracked files. Codex (AGENTS.md from cwd) and opencode (AGENTS.md / CLAUDE.md / CONTEXT.md from session directory) have no parallel field and ignore this option.
type ToolPolicy = {
defaultMode?: 'default' | 'acceptEdits' | 'plan' | 'dontAsk' | 'bypassPermissions'
allow?: string[] // e.g. ['Read', 'Bash:git status', 'mcp:server/tool']
deny?: string[]
ask?: string[]
}The parser lives in src/wire/policy.ts. Each backend translates the canonical pattern grammar to its native rule shape.
type McpServer = {
type: 'http'
name: string
url: string
headers?: { name: string; value: string }[]
}Phase 1 ships the http transport. The codex driver writes per-server entries into a temporary ~/.codex/config.toml; opencode adds servers via sdk.mcp.add per session.
Every backend implements this contract:
interface BackendDriver {
capabilities(): DriverCapabilities
initialize(params: InitializeRequest): Promise<InitializeResult>
newSession(params: NewSessionRequest): Promise<NewSessionResult>
prompt(sessionId: string, params: PromptRequest, emit: EventEmitter): Promise<PromptResult>
cancel(request: CancelRequest): Promise<void>
loadSession(params: LoadSessionRequest): Promise<NewSessionResult>
forkSession(params: ForkSessionRequest): Promise<NewSessionResult>
compact(request: CompactSessionRequest, emit: EventEmitter): Promise<void>
}
interface DriverCapabilities {
resume: boolean
fork: boolean
fileUpload: boolean
thinking: boolean
subagent: boolean
skillEvents: boolean
debug: boolean
askQuestion: boolean
}Capability gating runs at the dispatcher: loadSession rejects with MethodNotSupportedError (-32601) when resume is false; forkSession rejects when fork is false. The other six flags are advisory metadata for the orchestrator.
The bin (src/index.ts) is the only public surface. Importers get the runtime helpers, not the drivers themselves:
| Export | Purpose |
|---|---|
SupportedBackend |
'claude' | 'codex' | 'opencode' |
BackendNotConfiguredError, UnknownBackendError |
startup-time errors |
resolveBackendFromEnv(env) |
parse KODIZM_BACKEND from a captured env |
installShutdownHook() |
wire SIGTERM + SIGINT to graceful shutdown |
performShutdown(graceMs?) |
run the shutdown side-effects manually |
registerActiveRecorder(r) |
track a DebugRecorder for shutdown flush |
registerShutdownFlusher(fn) |
register the transport flush callback |
SHUTDOWN_GRACE_MS |
default 3s grace budget |
main() |
the bin's entry function |
Drivers, wire types, and createAcpServer are internal. To embed programmatically, import them directly from their module paths under src/.
import { ClaudeDriver } from '@kodizm/acp/src/backends/claude/driver.ts'
import { createAcpServer } from '@kodizm/acp/src/server/acp-server.ts'
import { createNdjsonTransport } from '@kodizm/acp/src/server/transport.ts'
import { query } from '@anthropic-ai/claude-agent-sdk'
const driver = new ClaudeDriver({
credentials: { type: 'subscription', token: process.env.CLAUDE_CODE_OAUTH_TOKEN! },
agentInfo: { version: '0.5.4' },
sdk: { query: ({ prompt, options }) => query({ prompt, options }) },
})
const server = createAcpServer({
transport: createNdjsonTransport({ readable, writable }),
backend: driver,
})
await server.serve()The codex and opencode drivers follow the same shape; their constructors take backend-specific factories (spawnFactory for codex, no factory for opencode since the SDK helper handles it).
| Method | Purpose |
|---|---|
initialize |
handshake; returns protocolVersion + agentInfo + capabilities |
session/new |
open a session; returns { sessionId } |
session/prompt |
drive a turn; streams sessionUpdate notifications, returns PromptResult |
session/cancel |
abort the in-flight prompt for a session |
session/load |
resume a prior session by id (gated on resume) |
session/fork |
branch a session with optional overrides (gated on fork) |
session/compact |
trigger manual context compaction |
PromptResult.stopReason is one of: end_turn, cancelled, process_died, max_tokens, tool_use, session_failed. When session_failed, the result also carries failureReason and failureDetail.
The SessionUpdateEvent discriminated union has 21 variants:
| Type | When |
|---|---|
output_chunk |
streaming model output |
thinking_chunk |
streaming reasoning |
tool_call_begin / progress / end |
tool lifecycle (one begin + one end per call) |
permission_request |
model wants to run a gated tool |
permission_deferred / permission_resumed |
Pattern B lifecycle |
question_request |
model asks the user a question |
usage |
token + cost rollup |
subagent_spawn / subagent_complete |
nested agent lifecycle |
skill_activation |
a skill loaded mid-turn (claude only) |
model_advertisement |
the actual model the backend chose |
process_died |
subprocess crash (codex / opencode) |
cancelled |
turn was cancelled |
compaction_started / compaction_completed |
context compaction; trigger: 'manual' | 'auto' |
debug_log |
one of 10 stages: rpc.in, rpc.out, sdk.message, sdk.error, tool.permission_request, tool.permission_response, session.config, driver.state_change, transport.spawn, transport.exit |
heartbeat |
periodic liveness signal |
session_failed |
structured lifecycle failure |
| Method | When |
|---|---|
sessionUpdate |
every event above flows as a notification |
session/request_permission |
orchestrator decides on a permission_request |
session/ask_user_question |
orchestrator answers a question_request |
session/dynamic_tool_call |
codex orchestrator-hosted tool dispatch |
session/codex_chatgpt_token_refresh |
codex chatgpt-mode token rotation |
session/permission_deferred_persist |
Pattern B write fallback when no deferredStore |
session/permission_deferred_state |
Pattern B read fallback when no deferredStore |
The permission and ask-question RPC names have legacy aliases (requestPermission, askUserQuestion); both forms route to the same handler.
A driver instance can resume a session that an earlier process started. Useful when a container restart, deploy, or crash interrupts an active turn.
- claude: standard
session/loadagainst the SDK's resume mode; the JSONL transcript on disk is authoritative. - codex:
CodexDriver.hydrateSession({ sessionId, codexThreadId, ... })replays codex'sthread/resumeagainst the persisted threadId. - opencode:
OpencodeDriver.loadSession({ sessionId, _meta: { opencodeSessionId } }); opencode's SQLite persistence survives process death.
Deferred permissions (the orchestrator decided to "ask later" on a tool gate) are persisted via either an injected DeferredPermissionStore or the session/permission_deferred_persist outbound RPC. The next process picks up where the prior one left off and emits permission_resumed.
session_failed carries one of seven reasons:
| Reason | Container action |
|---|---|
sdk_stall |
exit |
transport_error |
exit |
internal_panic |
exit |
protocol_violation |
exit |
sdk_throw |
stay alive (orchestrator may retry) |
auth_error |
stay alive (orchestrator can refresh credentials) |
rate_limit |
stay alive (orchestrator backs off) |
The exit decision lives in src/util/exit-policy.ts. The bin consults it after a session_failed and triggers graceful shutdown when true.
bun install
bun test test/unit # mocked; ~600 tests
bun test test/e2e # full ACP roundtrip
bun test test/integration # real-API smokes (requires creds)
bunx tsc --noEmit # typecheck
bunx biome check --write src test # lint + format
bun run build # compile to dist/index.jsThe integration suite gates each backend on its own auth probe; tests skip cleanly when credentials are not present.
Apache-2.0. See LICENSE.