Use your local coding-CLI subscriptions as one OpenAI-compatible HTTP API — with persistent session resume.
Model ids are <harness>/<model>. The harness is the agent runtime (Claude Code, Codex, opencode, claudish, …); the model is whatever that runtime can address. One string, both choices explicit.
Backends this is built for (✓ implemented, ◦ stubbed):
| Harness | Status | What it is |
|---|---|---|
claude/ |
✓ | Claude Code CLI — your Claude Max / Pro subscription |
claudish/ |
✓ | Claude Code + claudish — Claude's workflow, a different brain |
codex/ |
✓ | OpenAI Codex CLI — your ChatGPT Plus/Pro subscription |
opencode/ |
✓ | opencode — multi-provider; the vehicle for Kimi Code via the opencode-kimi-full plugin |
gemini/ |
✓ | Gemini CLI — Google's official Gemini coding CLI |
factory/ |
◦ | Factory Droid |
amp/ |
◦ | Sourcegraph Amp |
forge/ |
◦ | Forge Code |
<provider>/ |
✓ | Passthrough: openai/, anthropic/, moonshot/, zai/ — direct vendor API, not a CLI |
Personal productivity tool. Single-user by default. Loopback-only by default. No ambition to be a shared proxy.
Every AI lab now ships a CLI, each with its own subscription + better session economics than the metered API. cli-bridge exposes all of them as one OpenAI-compatible endpoint so your tools (editor, aider, tangle-router, a bash script) can switch harnesses with a single string.
<harness>/<model>
claude/sonnet # Claude Code + Anthropic Sonnet
claude/opus # Claude Code + Anthropic Opus
claude/claude-sonnet-4-5-20250929 # Claude Code + specific version
claudish/openrouter@deepseek/deepseek-r1 # Claude Code workflow, DeepSeek brain
claudish/google@gemini-2.0-flash # Claude Code workflow, Gemini brain
claudish/zai@glm-4.6 # Claude Code workflow, Z.AI brain
codex/gpt-5-codex # Codex CLI, Codex model
opencode/kimi-for-coding # opencode + kimi-full plugin (Kimi Code sub)
opencode/anthropic/claude-sonnet-4-5 # opencode's configured anthropic provider
gemini/gemini-2.5-pro # Gemini CLI with explicit model
gemini/gemini-2.5-flash # Gemini CLI Flash model
openai/gpt-4o # passthrough — OpenAI API, metered
zai/glm-4.6 # passthrough — Z.AI API, metered
The registry matches on the <harness>/ prefix; first-registered-first-match wins. bridge/claude (no model) defaults to whatever the harness default is.
When reaching cli-bridge via tangle-router's /api/chat, prefix the whole thing with bridge/:
bridge/claude/sonnet
bridge/claudish/openrouter@deepseek/deepseek-r1
bridge/opencode/kimi-for-coding
bridge/gemini/gemini-2.5-pro
The router's short-circuit strips the leading bridge/ and forwards the <harness>/<model> as-is.
git clone https://github.com/drewstone/cli-bridge.git
cd cli-bridge
pnpm install
cp .env.example .env
# edit .env to taste
pnpm verify # probes each configured backend, reports ready/unavailable
pnpm start
# → http://127.0.0.1:3344 (was 8787; changed to dodge port collisions)Prereqs: Node 22+. For each backend you want enabled, install + log in on the host. The install commands below are wrapped by:
pnpm install:harness -- claude
pnpm install:harness -- codex
pnpm install:harness -- opencode
pnpm install:harness -- kimi
pnpm install:harness -- gemini
pnpm install:harness -- pi
pnpm install:harnesses # all harnesses| Backend | Install | Auth |
|---|---|---|
claude |
npm i -g @anthropic-ai/claude-code |
claude /login (OAuth, opens browser) |
claudish |
claude above + run claudish locally, point CLAUDISH_URL at it |
claudish's own provider config |
codex |
brew install openai/homebrew-tap/codex |
codex login |
opencode |
brew install sst/tap/opencode (+ opencode-kimi-full plugin for Kimi Code) |
opencode login |
gemini |
npm install -g @google/gemini-cli |
Gemini CLI's official auth/login flow |
pi |
npm install -g @earendil-works/pi-coding-agent |
Provider credentials in ~/.pi/agent |
passthrough |
(none) | provider API keys in .env |
For a Nix-provisioned host shell with the shared prerequisites:
nix-shell nix/harness-profile.nix
sh scripts/install-harness.sh allcurl -N http://127.0.0.1:3344/v1/chat/completions \
-H 'Content-Type: application/json' \
-H 'X-Session-Id: my-first-session' \
-d '{
"model": "claude/sonnet",
"messages": [{"role": "user", "content": "say hi in 3 words"}],
"stream": true
}'Subsequent calls with the same X-Session-Id resume the conversation. Claude Code sees prior context, doesn't re-charge you for replaying it.
OpenAI Chat Completions.
Model id routes via harness prefix.
The OpenAI-compatible default is non-streaming; pass stream: true for SSE.
Session resume uses the session_id body field or X-Session-Id header.
Extra fields this bridge accepts beyond vanilla OpenAI:
cwd: persist a working directory for the session and run future resumed turns thereagent_profile: fullAgentProfileobjectmcp: standardised MCP server passthrough (see MCP passthrough)run_id: caller-owned durable job id (also accepted asX-Run-Id)
Behavior:
sandboxbackends honor the fullagent_profilenatively- local harness backends (
claude-code,codex,kimi-code,gemini) persist the full profile, honor the executable subset directly where possible, and compile the remaining context into a deterministic system-prompt preamble
Example:
curl http://127.0.0.1:3344/v1/chat/completions \
-H 'Content-Type: application/json' \
-H 'Authorization: Bearer '"$BRIDGE_BEARER" \
-d '{
"model": "codex/gpt-5.4-mini",
"session_id": "agent-builder-local",
"cwd": "/Users/drew/webb/agent-builder",
"agent_profile": {
"name": "local-coder",
"prompt": { "systemPrompt": "Be surgical. No placeholder logic." },
"skills": ["critical-audit"]
},
"messages": [{ "role": "user", "content": "inspect the repo and propose the smallest viable fix" }],
"stream": false
}'Once accepted, a chat job belongs to the bridge process rather than to the HTTP connection that dispatched it.
Closing an SSE response detaches that reader and does not cancel the backend CLI process.
Only POST /v1/runs/:id/cancel sends the backend abort signal.
Supply run_id in the body or X-Run-Id in the header to make dispatch idempotent.
If both are present they must match.
Ids are 1–128 characters, begin with an ASCII letter or digit, and may then contain letters, digits, ., _, :, or -.
The bridge binds the id atomically to the normalized execution request before admission or backend setup:
- An identical retry attaches to the existing job and never acquires a second process slot.
- A different request under the same id returns
409 run_identity_conflictwith both request digests. - The response carries
X-Run-IdandX-Run-Request-Digest; retain both with the trace.
For stream: true, every output delta has a monotonically increasing SSE id.
Reconnect with the same request and Last-Event-ID: N to receive only events after N.
An ahead cursor returns 409 invalid_replay_cursor.
A cursor older than the retained window returns 410 expired_replay_cursor; the bridge never silently restarts at event zero.
Last-Event-ID is rejected for non-streaming responses because a single JSON response cannot express partial replay.
Replay storage is explicitly bounded per process:
- At most
BRIDGE_RUN_MAX_REPLAY_DELTASdeltas are retained per run (default10000). - Terminal output expires after
BRIDGE_RUN_REPLAY_RETENTION_MS(default60000ms). - The run-id/request binding remains after output expiry for
BRIDGE_RUN_IDENTITY_RETENTION_MS(default86400000ms) so a late retry cannot accidentally execute the job again.
Do not recycle run ids after the identity window.
A bridge restart loses the in-memory registry; 404 after a restart means the old process is unknown, not proven stopped.
GET /v1/runs/:id?wait_ms=N returns the current snapshot and can wait up to 30 seconds for terminal state.
The state field is running while a reader is attached, detached while the job continues without readers, cancelling after explicit cancellation but before process exit, and terminal only after the backend iterator exits.
The separate status field records the outcome as running, done, error, or cancelled.
POST /v1/runs/:id/cancel?wait_ms=N returns:
202while cancellation was requested but process exit is not yet proven.200only withterminal: trueand the terminal run snapshot.404when this bridge process has no record of the run; this is not termination proof.
The wait_ms query is an integer from 0 through 30000 on both endpoints.
Lists model ids each ready backend claims, with which harness serves them.
JSON report per backend — ready / unavailable / error with detail.
Inspect / clear external-to-internal session mappings.
Render OpenSCAD source to STL + PNG (+ optional GLB). Generic — any project that wants a parametric-CAD rasterizer behind one bearer can call it.
curl -s http://127.0.0.1:3344/cad/render \
-H 'Content-Type: application/json' \
-H 'Authorization: Bearer '"$BRIDGE_BEARER" \
-d '{
"code": "cube(10);",
"outputs": ["stl", "png"],
"imageSize": [800, 600]
}' | jq '.ok, .durationMs, (.artifacts | keys)'Body shape:
{
code: string, // OpenSCAD source
outputs?: Array<"stl"|"png"|"glb">, // default ["stl","png"]
imageSize?: [number, number], // png only, default [800,600]
defines?: Record<string, string | number | boolean>, // openscad -D vars
}Response: { ok: true, artifacts: { stl?, png?, glb? }, durationMs, warnings: [] }
on success; { ok: false, error: string, durationMs } on openscad
compile failure or artifact-size overflow.
Dependencies: openscad on $PATH (provided by the hardware
nix profile in tangle-network/agent-dev-container). GLB output
additionally requires python3 + trimesh; if either is missing the
endpoint omits glb from artifacts and adds a warning rather than
failing. Configure the wall-clock budget via
CAD_RENDER_TIMEOUT_MS (default 60_000). Each artifact is capped at
10 MB.
OpenAI-compatible image generation, mounted on the standard /v1 path
so @tangle-network/tcloud's imageGenerate(...) (and any OpenAI Node
SDK with baseURL pointed at this bridge) Just Works without a custom
transport.
Default model: gpt-image-2. Override per-call via the OpenAI
standard model field.
curl -s http://127.0.0.1:3344/v1/images/generations \
-H 'Content-Type: application/json' \
-H 'Authorization: Bearer '"$BRIDGE_BEARER" \
-d '{
"prompt": "a small red square on a white background",
"size": "1024x1024",
"quality": "low",
"n": 1
}' | jq '.data[0].b64_json | length'Request body (schema is permissive — unknown fields pass through):
{
model?: string, // default "gpt-image-2"
prompt: string,
size?: string, // OpenAI-supported sizes
quality?: string, // low | medium | high | auto
n?: number, // default 1, capped at 10
response_format?: "b64_json"|"url",
// Any future OpenAI params (style, background, output_format, …) pass
// through unchanged — no schema bump required.
}Response is the upstream's body verbatim (OpenAI-shaped):
{ created, data: [{ b64_json, revised_prompt? }, …] }. On upstream
failure the bridge surfaces the upstream's status code and error body
unchanged so OpenAI-style error handling on the caller side keeps
working.
Dispatch order:
TANGLE_API_KEYset → forward to${TANGLE_ROUTER_URL or router.tangle.tools/v1}/images/generations. Router accounts for credits + applies the operator's routing policy. Canonical production path.OPENAI_API_KEYset → forward directly to OpenAI. Local-dev fallback.- Neither → HTTP 503 with an OpenAI-shaped
{error:{type:"service_unavailable", code:"no_image_backend"}}.
The bridge never logs prompts (potential PII); only model, size, n, dispatch route, upstream status, and duration are emitted on stderr.
Note on image editing. OpenAI's separate
/v1/images/editsendpoint (multipart, with a reference photo for the "place this in your space" workflow) is intentionally NOT proxied yet —tcloudclient doesn't surface it as of v0.4.6, and adding a custom shape would re-introduce the very transport asymmetry this route just removed. When the editing API lands intcloud, mount a sibling/v1/images/editsroute that mirrors the same router-vs-OpenAI fork.
Every backend cli-bridge wraps loads Model Context Protocol servers natively. Pass a single canonical shape in the request body and the bridge translates it to each CLI's native loader — no per-backend boilerplate, no prompt-injected tool surrogates: every CLI speaks MCP.
The shape mirrors Claude Code's mcp-config.json so the same JSON can
be forwarded to every backend that natively supports MCP. Pass it as
a top-level mcp field on the chat-completions body, or as the
X-Mcp-Config HTTP header (JSON-encoded; body wins on per-name
collision).
Per-server fields:
| field | type | notes |
|---|---|---|
type |
stdio, http, sse |
optional; inferred from command/url when missing |
command |
string | stdio: executable to spawn |
args |
string[] | stdio: argv after command |
env |
Record<string,string> |
stdio: env vars for the spawned MCP server |
url |
string | http/sse: endpoint |
headers |
Record<string,string> |
http/sse: request headers (auth, etc.) |
enabled |
boolean | set false to drop without removing the entry |
timeout |
number (ms) | per-tool-call timeout |
agent_profile.mcp (sandbox-native shape) is also honored — request
body mcp.mcpServers wins on per-name collision so caller's per-turn
intent always overrides profile defaults.
| backend | stdio MCP | http/sse MCP | loader mechanism |
|---|---|---|---|
| claude | yes | no (caveat) | --mcp-config <tempfile> (canonical mcp-config.json shape) |
| codex | yes | yes | CODEX_HOME=<tempdir> with synthesised config.toml |
| kimi | yes | no | --mcp-config-file <tempfile> (same shape as claude) |
| opencode | yes | no | OPENCODE_CONFIG=<tempfile> (opencode's per-config schema) |
| gemini | no | no | not wired until Gemini exposes/validates per-invocation MCP |
stdio: every MCP-enabled backend loads stdio MCP servers — command, args,
and env round-trip through the materialised config file unchanged
(verified end-to-end in tests/mcp-passthrough.test.ts).
http/sse caveat: claude/kimi/opencode load HTTP MCP via the
respective CLI's separate mcp add --transport http registry, which
is per-user persistent state and not safe for cli-bridge to touch on
every request. HTTP entries you pass to those backends are dropped at
materialisation. Use codex for stateless remote-MCP passthrough until
the upstream CLIs expose a per-invocation HTTP MCP loader.
Claudish is a separate tool (Hono-based Anthropic proxy). Run it locally:
brew install claudish # or install-from-source per its repo
claudish --port 3456
# then in cli-bridge .env:
CLAUDISH_URL=http://127.0.0.1:3456
BRIDGE_BACKENDS=claude,claudish,passthroughNow every claudish/<model> call spawns Claude Code with ANTHROPIC_BASE_URL=http://127.0.0.1:3456 — Claude Code's workflow, whatever-you-configured's brain.
VerticalBench — swap claude -p subprocess calls for HTTP to cli-bridge with X-Session-Id: leaf-<id>. Durable session state across runs, no re-billing replays.
Agent Builder dev — BYOK_CLI_ENDPOINT=http://host.docker.internal:3344 in .dev.vars. Forge drives your Claude Code subscription locally during development. Never ship that to production.
PR reviews & automations — any bash cron / GitHub Action can hit POST /v1/chat/completions with a stable X-Session-Id.
Default behavior spawns the CLI on the host. That's fine for one caller; under N concurrent chat() calls you hit:
- shared
~/.claude(or~/.kimi,~/.codex,~/.config/opencode) OAuth state - shared scratch dirs (multiple CLI processes touching the same tmp)
- single CLI subprocess instance contending with itself
The Docker executor solves all three: each chat() runs inside a
pre-warmed container slot, and session_id sticks the same caller to
the same slot so --resume reads the same on-disk transcript
turn-to-turn. Works for every subprocess backend — claude, kimi,
gemini, codex, opencode, pi — through the same Spawner abstraction.
# 1. build the unified runtime image once (has all coding CLIs installed)
pnpm docker:build:runtime
# Or build a smaller subset/layerable image for a specific deployment.
docker build -f docker/Dockerfile.cli-runtime \
--build-arg CLI_BRIDGE_HARNESSES=codex,gemini \
-t cli-bridge-cli-runtime:codex-gemini .
# 2. enable per backend (any subset)
cat >> .env <<'EOF'
CLAUDE_EXECUTOR=docker
CLAUDE_DOCKER_POOL_SIZE=4
CLAUDE_DOCKER_WORKSPACE_ROOT=/tmp/cli-bridge-workspaces
CLAUDE_DOCKER_NETWORK=research-services
KIMI_EXECUTOR=docker
KIMI_DOCKER_POOL_SIZE=2
GEMINI_EXECUTOR=docker
GEMINI_DOCKER_POOL_SIZE=2
CODEX_EXECUTOR=host
OPENCODE_EXECUTOR=host
EOF
# Or flip everything at once:
# echo 'BRIDGE_DEFAULT_EXECUTOR=docker' >> .env
# 3. start as usual
pnpm start
# [cli-bridge] claude executor: docker pool size=4 image=cli-bridge-cli-runtime:latest
# [cli-bridge] kimi executor: docker pool size=2 image=cli-bridge-cli-runtime:latest
# [cli-bridge] gemini executor: docker pool size=2 image=cli-bridge-cli-runtime:latestOAuth mount modes:
share(default) — bind-mounts host~/.claude(etc) into every slot. Simplest; concurrent token-refresh can race on the same session DB.per-slot— each slot gets its own named docker volume. Full OAuth isolation; one<cli> /loginper slot on first run.
Coding runs can expose one dedicated host workspace tree with
<NAME>_DOCKER_WORKSPACE_ROOT=/absolute/path. The bridge canonicalizes an
existing directory, rejects /, mounts it read-write at the identical path in
every worker, and rejects request cwd values outside it. OAuth remains a
separate mount controlled by the mode above. Use a narrow per-experiment root,
not a home or repository parent directory.
Pool workers can join one existing Docker network with
<NAME>_DOCKER_NETWORK=<network-name>. The bridge passes the validated name to
docker run --network; it does not create or remove the network. Leave this
unset to retain Docker's default networking behavior.
cli-bridge spawns pool containers by talking to the host docker daemon — pool slots are siblings of cli-bridge, not nested. Two shapes work:
-
cli-bridge on host (recommended for autoresearch / dev). The bridge runs as
pnpm start; pool containers spawn directly via the host docker daemon. Callers (orchestrators, evals) hit127.0.0.1:3344. -
cli-bridge in a container (deployment). The compose stack bind-mounts
/var/run/docker.sockso the bridge can drive the host daemon to spawn pool slots as siblings on the host. Set<NAME>_DOCKER_HOST_CONFIG_DIRto a HOST path (not a path inside the bridge container) — the daemon resolves binds against the host fs.
Either way, an orchestrator running in its own container hits the bridge
at host.docker.internal:3344 (Docker Desktop) or the bridge gateway
IP (Linux). No DinD anywhere.
See deploy/README.md for Hetzner box (Docker or systemd). Remote deploy requires BRIDGE_BEARER — cli-bridge refuses to bind non-loopback without one.
- Explicit in the model id, not the env. The
<harness>/<model>scheme means "what you type is what runs." No mode toggles that change behavior under the same id. - Harnesses are independent. Each is a class implementing
Backend; add a new one insrc/backends/*.ts, register it insrc/server.ts. - Single-user assumption is deliberate. No per-call user auth beyond the optional bearer.
MIT
{ "model": "claude/sonnet", "messages": [{ "role": "user", "content": "list my repos" }], "mcp": { "mcpServers": { "github": { "type": "stdio", "command": "npx", "args": ["-y", "@modelcontextprotocol/server-github"], "env": { "GITHUB_TOKEN": "ghp_xxx" } }, "linear": { "type": "http", "url": "https://mcp.linear.app/mcp", "headers": { "Authorization": "Bearer lin_xxx" } } } } }