An HTTP runtime that turns Claude Code into a coding-agent service: the Claude Agent SDK drives a decoupled Claude Code CLI, exposing multi-turn coding sessions (SSE streaming), OpenTelemetry observability, and an OpenAPI 3.1 spec.
See CHANGELOG.md for the release history.
coding-agent-runtime composes the Claude Agent SDK with a separately installed Claude Code CLI into a stateless HTTP service:
- The SDK drives the standalone Claude Code CLI via
pathToClaudeCodeExecutable— they talk over stdio / stream-json and can be upgraded independently. - The container is stateless;
CLAUDE_CONFIG_DIRand the working directory are injected via mounts. - The model backend is pluggable (via
ANTHROPIC_BASE_URL): this repo uses MiniMax-M3 as the test backend; point it at the official Anthropic API or any Anthropic-compatible gateway instead.
- Multi-turn coding sessions:
POST /sessionsstarts a new session and runs the first turn;POST /sessions/:id/turnscontinues viaresumewith preserved context. Everything streams over SSE (init/assistant/tool_result/result; tool uses ride theassistantevent'stoolUsesarray). WithINCLUDE_PARTIAL_MESSAGES=1, token-leveldeltaevents ({ index, text }) also stream as the assistant text is generated; the completeassistantevent still follows each, so render the deltas or the final block, not both. When a turn delegates to a sub-agent, the sub-agent's activity carriesparentToolUseId(the id of the delegatingTasktool_use) onassistant/tool_result/deltaevents; top-level events omit the field. - Multimodal image input (opt-in): a turn request may carry a
contentarray oftextand base64imageblocks instead of a plainprompt(exactly one of the two). Whencontentis present the runtime uses the SDK's streaming-input mode; a plainpromptkeeps the unchanged single-message path. Input is size-bounded — a request-body limit (RUNTIME_MAX_BODY_BYTES, default 12 MiB) returns413before buffering, and per-content limits (≤16 images, ≤10 MiB base64) return400. The web playground composer supports image attach (click or drag-and-drop). Interactive approvals (canUseTool/AskUserQuestion) are out of scope — they need a duplex transport the unidirectional SSE stream can't provide. - Structured outputs (opt-in): a turn request may include an
outputFormatenvelope ({ "type": "json_schema", "schema": { … } }); the runtime forwards the schema to the Agent SDK and surfaces the validatedstructured_outputon the SSEresultevent (or theerror_max_structured_output_retriessubtype on theerrorevent if the model can't satisfy the schema). OmitoutputFormatand behavior is unchanged. - Session management: list / info / transcript / stop (abort the active turn) / delete; runtime state tracks turn count, cumulative tokens, cost, status, and changedFiles.
- Observability (OpenTelemetry): custom
agent.turnand tool spans + an injectedTRACEPARENTthat stitches the child CLI's native spans into the same trace; usage → span attributes. Ships a compose stack (OTel Collector + Jaeger + Prometheus) and exposesX-Trace-Idon responses. - OpenAPI 3.1:
GET /openapi.json+GET /docs(Swagger UI). - Security & resilience: a parser-based PreToolUse Bash allowlist (blocks non-allowlisted commands — even under
bypassPermissions) plusdisallowedToolsas a backstop; standard container hardening (read-only rootfs,cap_drop: ALL,no-new-privileges, pids/memory/CPU limits, tmpfs); an SSE:keepaliveheartbeat to survive idle proxies. Seedocs/superpowers/SECURITY-p3.md. - Web Playground (
apps/web, Vite + React): connect to the runtime, run multi-turn conversations, embed Swagger, and deep-link to Jaeger by traceId. A delegatedTaskturn renders as a nested card: the subagent's tool calls (and, withRUNTIME_FORWARD_SUBAGENT_TEXT=1, its text) group inside the delegating Task card viaparentToolUseId.
HTTP / SSE
Client / Web ─────────────────► Runtime (Hono + OpenAPIHono)
(apps/web) │ session registry · OTel turn/tool spans
│
▼ Claude Agent SDK
pathToClaudeCodeExecutable
│ stdio / stream-json
▼
Claude Code CLI (standalone install)
│ Anthropic-compatible API
▼
Model backend (MiniMax / Anthropic / …)
OTel spans ──► OTel Collector ──► Jaeger (traces) / Prometheus (metrics)
| Path | Description |
|---|---|
apps/runtime |
HTTP service (Hono + OpenAPIHono): SDK-drives-CLI, session registry, OTel, security hook |
apps/web |
Standalone front-end Playground (Vite + React, browser-only client of the runtime) |
apps/bench |
Benchmark harness: drives the runtime as a black box over HTTP/SSE and scores it (hello-bench + SWE-bench; see docs/benchmarks.md) |
otel/ |
OTel Collector / Prometheus config |
docs/superpowers/ |
Security threat model (SECURITY-p3.md) |
docker-compose.yml |
runtime + otel-collector + jaeger + prometheus, one stack |
- Node ≥ 22; pnpm (via corepack; this repo pins
pnpm@10.34.1) - Docker (for containers or the observability stack)
- An Anthropic-compatible model backend + API key
cp .env.example .env
# Edit .env: set ANTHROPIC_API_KEY at minimum; adjust ANTHROPIC_BASE_URL / RUNTIME_DEFAULT_MODEL as neededcorepack enable
pnpm install
pnpm --filter @app/runtime dev # tsx watch, listens on 127.0.0.1:8080 by default
curl http://127.0.0.1:8080/healthz # -> {"status":"ok"}pnpm --filter @app/web dev # Vite dev server; set the connection bar to 127.0.0.1:8080docker compose up -d --build
# runtime : http://127.0.0.1:8080
# Jaeger : http://localhost:16686
# Prometheus: http://127.0.0.1:9090curl -N -X POST http://127.0.0.1:8080/sessions \
-H 'Content-Type: application/json' \
-d '{"prompt":"Create /workspace/hello.txt with the content hello"}'| Method | Path | Description |
|---|---|---|
GET |
/healthz |
Health check |
GET |
/config |
Runtime config (default model, allowed models, reasoning effort, max-turns backstop, Jaeger deep-link base, version, loaded-extensions summary, …) |
POST |
/sessions |
Create a session and run the first turn (SSE) |
POST |
/sessions/:id/turns |
Continue a session (resume, SSE) |
GET |
/sessions |
List sessions |
GET |
/sessions/:id |
Session details (turns / cumulative tokens / cost / status / changedFiles) |
GET |
/sessions/:id/transcript |
Full message transcript |
POST |
/sessions/:id/stop |
Abort the active turn |
DELETE |
/sessions/:id |
Delete a session |
GET |
/openapi.json |
OpenAPI 3.1 spec |
GET |
/docs |
Swagger UI |
Full schema at /docs.
| Variable | Default | Description |
|---|---|---|
ANTHROPIC_BASE_URL |
https://api.minimaxi.com/anthropic |
Model backend (Anthropic-compatible gateway) |
ANTHROPIC_API_KEY |
(required) | Backend API key |
RUNTIME_DEFAULT_MODEL |
MiniMax-M3 |
Default model |
RUNTIME_EFFORT |
max |
Reasoning effort: low/medium/high/xhigh/max |
PORT |
8080 |
Listen port |
RUNTIME_HOSTNAME |
127.0.0.1 |
Bind address (set to 0.0.0.0 inside the container by the Dockerfile) |
RUNTIME_ALLOWED_MODELS |
(empty = unrestricted) | Model allowlist (comma-separated) |
CORS_ORIGINS |
* |
Allowed front-end origins |
RUNTIME_BASH_ALLOWLIST |
(built-in default) | Bash command allowlist (matches argv[0] basename only) |
RUNTIME_SSE_HEARTBEAT_MS |
20000 |
SSE heartbeat interval in ms (0 = disabled) |
RUNTIME_MAX_TURNS |
100 |
Runaway backstop: max agentic turns per turn (0 = unlimited) |
RUNTIME_TURN_TIMEOUT_MS |
0 |
Optional per-turn wall-clock deadline in ms (0 = disabled) |
RUNTIME_MAX_CONCURRENT_TURNS |
2 |
Admission limit; excess turns get HTTP 429 (0 = unlimited) |
RUNTIME_SESSION_TTL_MS |
0 |
Idle-session GC: drop+reclaim a session after this idle time (0 = disabled) |
RUNTIME_GC_INTERVAL_MS |
3600000 |
Idle-session GC sweep interval (only active when TTL > 0) |
RUNTIME_IDEMPOTENCY_TTL_MS |
600000 |
Idempotency-Key retention window in ms; duplicate key → HTTP 409 (0 = disabled) |
RUNTIME_EXTENSIONS_FILE |
(unset = none) | Path to a declarative extensions manifest (JSON); see Extensions |
RUNTIME_FORWARD_SUBAGENT_TEXT |
(unset = off) | 1 forwards sub-agent text/thinking on the stream (as events with parentToolUseId) for nested transcript rendering |
RUNTIME_TOOL_SEARCH |
(unset = CLI default) | Tool search knob, forwarded to the CLI as ENABLE_TOOL_SEARCH: true / false / auto / auto:N; invalid values are treated as unset. See Tool search |
Full list and notes in .env.example.
The runtime ships a self-contained extension subsystem (apps/runtime/src/extensions/) for adding custom tools, external MCP servers, hooks, skills, and sub-agents without touching the core. Every contribution is built once at startup and merged onto the secure base Options by a single pure composer (applyExtensions); with nothing configured, behavior is unchanged.
See docs/extensions.md for the full authoring guide — the field-by-field merge semantics, worked code-tier examples, the declarative manifest schema, and troubleshooting.
Two authoring tiers:
- Code tier (
builtin.ts) — for in-process custom tools (createSdkMcpServer+tool()) and hook callbacks, which are live JS and can't be expressed as data. Implement theExtensioncontract (name+setup(ctx)) and add it toBUILTIN_EXTENSIONS; it compiles into the image. - Declarative tier (
RUNTIME_EXTENSIONS_FILE) — a JSON manifest for the serializable bits: external MCP servers (stdio/SSE/HTTP), sub-agents, local plugin dirs, skill enablement, and discovery dirs. No code, no rebuild. Secret-bearing values (stdioenv, sse/httpheaders) support${VAR}/${VAR:-default}environment-variable expansion, so credentials stay out of the file.
⚠️ Trust boundary. Extensions are supplied by the operator, not by remote users. The composer structurally prevents a contribution from changing the security perimeter (permissionMode,disallowedTools,settingSources,env, …) and always keeps the Bash allowlist hook first. But custom tools / MCP servers run outside the Bash allowlist (it matches onlyBash), so the container hardening (read-only rootfs,cap_drop: ALL, egress allowlist) is their real backstop. Only load extensions you trust.
With tool search on, the Claude Code CLI defers most tool schemas (mainly MCP tools) out of the system prompt and the model loads them on demand by calling a ToolSearch tool — worth it when a manifest contributes many MCP servers, where inlining every schema eats context. RUNTIME_TOOL_SEARCH sets the CLI's ENABLE_TOOL_SEARCH for each turn's child process: true, false, auto (the CLI decides), or auto:N. It is a runtime knob, deliberately not an extension contribution (Options.env is security perimeter, and the SDK has no corresponding Options field). Unset means the runtime leaves the variable alone — an ENABLE_TOOL_SEARCH already in the environment still passes through; when both are set, the validated RUNTIME_TOOL_SEARCH wins.
Caveats:
- Non-official model backends (e.g. the MiniMax-M3 test backend behind
ANTHROPIC_BASE_URL): the CLI disables tool search by default; preferauto, which degrades gracefully. Forcingtruemakes turns fail unless the proxy forwardstool_referenceblocks. Haiku-class models don't support tool search at all. - Deferred tools are a presentation change, not a perimeter change: they remain governed by the same
disallowedTools/ hook rules once loaded. ENABLE_TOOL_SEARCHis a CLI-internal contract, not a public SDK option; it may drift across CLI upgrades. The runtime pins the CLI version, defaults to unset, and fails leniently (an invalid value is treated as unset), so the worst drift outcome is the knob going inert — never a broken turn.GET /configexposes the configured value astoolSearch(null= unset). Whether a given turn actually used tool search shows up as aToolSearchtool call in that turn's stream.
apps/bench (the @app/bench package) measures the runtime's coding ability against standard benchmarks. It is an external orchestrator — it drives the runtime as a black box over the public HTTP/SSE contract and scores out-of-band — not an extension, so it never touches the runtime core or its security perimeter.
hello-bench— three in-repo toy tasks, scored locally, zero external setup:node scripts/bench.mjs --benchmark hello-benchagainst a running runtime.swe-bench— the SWE-bench Lite curated subset. Each instance checks out its repo at the base commit (a shallow fetch by SHA), drives a turn, and the run's predictions are scored by the officialswebenchDocker harness; the repo commits only the curated instance-id list, not issue text or gold patches.
Each run snapshots the config tuple that produced it (benchmark, subset, backend label, model, effort, max-turns, prompt-scaffold version) and embeds it in the report. That tuple's identity subset is a baseline key: --accept commits a run as the baseline for its key, --compare fails (non-zero exit) when a later run drops below it, and --update-history appends to a per-key JSONL trail. BENCHMARKS.md is generated from the committed baselines (node scripts/bench.mjs emit-markdown).
See docs/benchmarks.md for the SWE-bench prerequisites (git, a downloaded dataset file, python + Docker + swebench), the run flags, baseline/regression tracking, and how to read the report. The harness's own vitest suite runs in pnpm verify with no model, Docker, or network.
CI runs hello-bench automatically (the Bench workflow, not a required check): every PR gets a --compare regression gate against the committed baseline, a weekly scheduled run tracks the trend, and a manual dispatch with accept=true produces a baseline artifact for a human to review and commit — see docs/benchmarks.md "CI automation". SWE-bench runs stay out of CI (the grader's Docker images don't fit a hosted runner).
After docker compose up, a single turn produces one unified trace: a custom agent.turn root with custom tool spans plus the child CLI's native claude_code.* spans, carrying gen_ai.usage.* token/cost attributes. In the Playground, click "Open trace in Jaeger" to deep-link by traceId. With a bare docker run (no OTEL_EXPORTER_OTLP_ENDPOINT), telemetry is automatically disabled and spans are no-ops.
- No HTTP authentication: the design assumes a trusted network / local host. Do not expose it directly to the public internet without an auth layer.
- Two-layer Bash control: a parser-based PreToolUse allowlist (splits
&& || ; |, stripstimeout/nice/envwrappers, takes theargv[0]basename) plusdisallowedToolsas a backstop. Residual risks and the threat model are indocs/superpowers/SECURITY-p3.md. - Container hardening: read-only rootfs,
cap_drop: ALL,no-new-privileges, resource limits, tmpfs, non-root; an optionalcontainer/egress-allowlist.shtightens egress. - Secrets: real secrets live only in a local
.env(gitignored) — never commit them; the in-repo.env.examplecontains only placeholders.
This runtime follows the Agent SDK hosting guide: the SDK supervises a claude CLI subprocess that owns a shell, a working directory, and on-disk session transcripts. The defaults below target a single-container, single-tenant deployment (one container per user/task).
Production guards (enabled by default). The SDK has no top-level session timeout and no built-in concurrency cap, so the runtime adds:
- Runaway backstop —
RUNTIME_MAX_TURNS(default 100) bounds agentic round-trips per turn;RUNTIME_TURN_TIMEOUT_MS(default off) adds an optional wall-clock deadline that aborts the turn. - Admission control —
RUNTIME_MAX_CONCURRENT_TURNS(default 2) rejects excess turns with429so concurrent subprocesses cannot OOM the host. Size it withagents = (RAM − overhead) / per-session RAM ceiling(≈1 GiB/session) and keep it in step with the containermem_limit. - Duplicate-turn guards — a per-session in-flight lock rejects a second turn on a session that already has one running with
409(so a retried/duplicatePOSTcan't run two CLIs against the one workspace or orphan the in-flight abort handle). A client can additionally send anIdempotency-Keyheader for at-most-once submission: a duplicate key (in-flight, or completed withinRUNTIME_IDEMPOTENCY_TTL_MS, default 10m;0disables) also gets409. Both are independent of the cross-session429admission limit. - Disk reclamation —
RUNTIME_SESSION_TTL_MS(default off) GCs idle sessions and their transcripts on a long-running container.
Put auth at a gateway. Per the hosting guide, the agent should receive pre-authenticated requests and must not validate user tokens itself — so the runtime has no inbound auth and binds loopback by default. Front it with an authenticating gateway before exposing it.
Session persistence is single-replica. Transcripts live on the mounted CLAUDE_CONFIG_DIR volume (the durable source of truth; the in-memory registry is disposable). This is sufficient for one container or a shared network volume. To scale to multiple replicas, you must additionally either pin each sessionId to one container with consistent-hashing sticky routing, or attach a SessionStore adapter (S3/Redis/Postgres) via Options.sessionStore and handle mirror_error — neither is wired up today.
Multi-tenant isolation is out of scope. Because the deployment model is single-tenant, all sessions share one cwd (/workspace) and one CLAUDE_CONFIG_DIR. Note that concurrent sessions for the same user share /workspace and can overwrite each other's files. Serving mutually-untrusted tenants from one container would additionally require per-tenant cwd/CLAUDE_CONFIG_DIR, settingSources: [], and CLAUDE_CODE_DISABLE_AUTO_MEMORY=1 (see the hosting guide's multi-tenant section).
SDK × CLI version pinning. The SDK (@anthropic-ai/claude-agent-sdk) and the standalone CLI (@anthropic-ai/claude-code, set via CLAUDE_CODE_VERSION in the Dockerfile) are deliberately decoupled and talk over stdio/stream-json. They are version-pinned together; CI's smoke job runs one real turn end-to-end to catch drift that the stubbed unit tests cannot. Review both changelogs and re-run the smoke turn when bumping either.
pnpm verify # Biome CI + typecheck + test + build (mirrors required CI)
pnpm -r run test # all tests (runtime + web)
pnpm --filter @app/runtime typecheck # type check
pnpm check # Biome format + lint (biome check --write .)For an end-to-end check, run one real turn against a running runtime:
pnpm --filter @app/runtime dev # start the runtime on 127.0.0.1:8080
node scripts/smoke.mjs # polls /healthz, runs one turn, asserts init + resultSee CONTRIBUTING.md for the full plan → TDD → verify → review → PR → squash workflow, also encoded as coding-agent-runtime-* skills in .claude/skills/.
MIT © 2026 OpenSDLC-Dev