kodizm-acp
ACP bridge that drives the Claude Code and opencode CLIs through one canonical wire.
What it is
@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.
Two CLIs are supported: Claude Code (via @anthropic-ai/claude-agent-sdk) as the primary backend, and opencode (via createOpencodeServer from @opencode-ai/sdk) as the secondary one.
How it works
+------------------+ JSON-RPC over +------------------+ native protocol +-----------+
| Orchestrator | <----- NDJSON ----> | AcpServer | <-------------------> | Backend |
| (Kodizm core) | (stdio/pipe) | + BackendDriver | (SDK / subproc) | CLI |
+------------------+ +------------------+ +-----------+
KODIZM_BACKEND selects the driver at process boot. One process per backend.
AcpServer validates every inbound request against the canonical schema, then routes to the BackendDriver interface.
- Each driver maps the canonical request to its CLI's native shape. Stream events flow back through
emit.send() and surface as sessionUpdate notifications 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.
Backend support
session/new, session/prompt, session/cancel | yes | yes |
session/load (resume) | yes | yes |
session/fork | yes | yes |
session/compact (manual) | yes | yes |
| Image content blocks | yes | yes |
Token + cost rollup (usage event) | yes | yes |
additionalDirectories (sandbox) | yes | n/a |
mcpServers injection | yes | yes |
systemPrompt replace + append | yes | yes |
skills pre-load | yes | n/a |
Permissions (permission_request) | yes | yes |
askUserQuestion | yes | yes |
| Subagent events | yes | yes |
| Thinking events | yes | yes |
| Cross-process Pattern B resume | yes | yes |
| Debug capture | yes | yes |
[!NOTE]
The published bin (kodizm-acp from dist/index.js) wires both backends: set KODIZM_BACKEND to claude or opencode and the bin resolves the matching driver itself.
Install
bun add @kodizm/acp
Requires Bun >= 1.1.0. The opencode CLI must be installed separately when you use that backend.
Quick start
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/acp
Replace the credential pair with ANTHROPIC_API_KEY=... for the api-key path.
Configuration
Environment variables
KODIZM_BACKEND | yes | claude / 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) |
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 a spawned backend subprocess's stderr to parent stderr |
Stdout is reserved for ACP frames. Logs go to stderr.
Session options (NewSessionRequest)
type NewSessionRequest = {
cwd: string
mcpServers: McpServer[]
additionalDirectories?: string[]
systemPrompt?: string | { append: string }
model?: string
skills?: string[]
toolPolicy?: ToolPolicy
autoCompact?: boolean
permissionTimeoutMs?: number
permissionDeferTimeoutMs?: number
debug?: boolean
debugCaptureRawSdk?: boolean
debugCaptureRpc?: boolean
heartbeatIntervalMs?: number
inactivityThresholdMs?: number
settingSources?: ('user' | 'project' | 'local')[]
_meta?: Record<string, unknown>
}
[!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. Opencode (AGENTS.md / CLAUDE.md / CONTEXT.md from session directory) has no parallel field and ignores this option.
Tool policy
type ToolPolicy = {
defaultMode?: 'default' | 'acceptEdits' | 'plan' | 'dontAsk' | 'bypassPermissions'
allow?: string[]
deny?: string[]
ask?: string[]
}
The parser lives in src/wire/policy.ts. Each backend translates the canonical pattern grammar to its native rule shape.
MCP servers
type McpServer = {
type: 'http'
name: string
url: string
headers?: { name: string; value: string }[]
}
The http transport ships today. Opencode adds servers via sdk.mcp.add per session.
API reference
BackendDriver
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.
Public exports
The bin (src/index.ts) is the only public surface. Importers get the runtime helpers, not the drivers themselves:
SupportedBackend | 'claude' | '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/.
Programmatic embedding
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 opencode driver follows the same shape; it takes no spawn factory, since the SDK helper handles the subprocess.
Wire reference
JSON-RPC methods (inbound)
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.
sessionUpdate events
The SessionUpdateEvent discriminated union has 21 variants:
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 (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 |
Outbound RPCs (server to orchestrator)
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/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.
Cross-process resume (Pattern B)
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/load against the SDK's resume mode; the JSONL transcript on disk is authoritative.
- 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.
Failure handling
session_failed carries one of seven reasons:
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.
Development
bun install
bun test test/unit
bun test test/e2e
bun test test/integration
bunx tsc --noEmit
bunx biome check --write src test
bun run build
The integration suite gates each backend on its own auth probe; tests skip cleanly when credentials are not present.
License
Apache-2.0. See LICENSE.