A WebSocket gateway that spawns two official CLI/SDK agent tools as subprocesses and drives them as interchangeable backends for a custom chat UI:
- Codex, via
codex app-server(newline-delimited JSON, nojsonrpcenvelope). - Claude, via the official Zed-built ACP adapter
@zed-industries/claude-agent-acp(newline-delimited JSON-RPC 2.0 / Agent Client Protocol), built on Anthropic's Claude Agent SDK.
Both backends implement the same AgentBackend interface
(src/backends/AgentBackend.ts), so the WebSocket bridge
(src/server.ts) is entirely backend-agnostic.
This is a foundation to build a chat UI on top of — there is no auth
handling, no persistence, and no multi-tenant pooling beyond a simple
Map<sessionId, backend> per connection.
Auth is out of scope for this project — do this first, once, outside of this repo:
codex login— thecodexCLI must be installed and authenticated.claude login— Claude Code must be installed and authenticated (the ACP adapter reuses that login).
Both CLIs must be reachable on PATH (codex, and npx for
@zed-industries/claude-agent-acp, which is spawned on demand via npx and
does not need to be pre-installed globally, though pre-installing avoids the
first-run npx download delay).
npm installnpm run dev # tsx watch — auto-restarts on file changes
# or
npm run build && npm startThe server listens on ws://localhost:8787 by default (override with the
PORT env var).
Each session's backend, model, provider, and any extra backend config are
resolved by src/config.ts through an optional config file plus env
overrides — this is a validated per-session selection layer, not
persistence or auth (those remain out of scope, see Notes/limitations below).
- The gateway looks for
gateway.config.yaml,gateway.config.yml, orgateway.config.jsonin the working directory, or a path given via theGATEWAY_CONFIGenv var. No file is required — with none present, a session must specify a backend explicitly (or rely onGATEWAY_DEFAULT_BACKEND/GATEWAY_DEFAULT_MODEL). GATEWAY_DEFAULT_BACKENDandGATEWAY_DEFAULT_MODELenv vars, when set, override the config file'sdefaults.- See
gateway.config.example.yamlfor a full annotated example, includingdefaults, namedaliases(a single shortcut resolving to a full backend/model/provider selection), per-backendmodelsallowlists, and codexproviders(with optionalconfigmerged into the resolved selection'sbackendConfig, forwarded to codex'sthread/start). provideris codex-only. Selecting aproviderfor theclaudebackend is rejected by the resolver with a clear error.- Config is loaded once at server startup (no hot-reload); all connections on that process share the same resolved config.
Client -> server messages:
model, provider, and alias on start are all optional. If alias is
given, it fully determines the selection (backend/model/provider) and the
other fields are ignored by the resolver. Otherwise backend/model/
provider are validated (and defaulted from config) via resolveSelection;
an invalid selection (unknown backend/alias/provider, model outside an
allowlist, or a provider set for claude) gets an error reply and no
backend is started.
Server -> client messages:
{ "type": "session_started", "sessionId": "...", "backend": "codex" | "claude", "model": "...", "provider": "..." }
{ "type": "agent_event", "sessionId": "...", "event": { /* AgentEvent, see below */ } }
{ "type": "approval_requested", "sessionId": "...", "request": { "id", "description", "kind", "command?", "diff?" } }
{ "type": "error", "message": "..." }
{ "type": "models_available", "defaults": { ... }, "aliases": { ... }, "backends": { "claude": { "models": [...], "providers": [] }, "codex": { "models": [...], "providers": [...] } } }session_started.model / .provider echo the resolved selection (after
defaults/alias expansion) so the UI can confirm what actually started, even
when the client only sent alias or nothing at all. models_available (the
reply to list_models) is the config-derived catalog from describeModels
— no network calls, no CLI spawn.
AgentEvent (shared by both backends — see src/backends/AgentBackend.ts):
type AgentEvent =
| { type: "text_delta"; text: string }
| { type: "thought_delta"; text: string }
| { type: "tool_call_started"; id: string; name: string; kind: string; args?: unknown }
| { type: "tool_call_completed"; id: string; status: string; result?: unknown }
| { type: "plan_update"; entries: unknown[] }
| { type: "usage_update"; input?: number; output?: number; total?: number; contextWindow?: number }
| { type: "turn_completed"; stopReason: string }
| { type: "turn_error"; message: string; shouldRetire?: boolean };Approval requests are fail-closed: if the client doesn't send an
approval_response within 60 seconds, the gateway automatically denies the
request so a hung UI can never leave the underlying agent blocked
indefinitely.
src/
backends/
AgentBackend.ts # shared interface + AgentEvent union
ndjson.ts # line framing, async queue, stderr ring buffer, process teardown
CodexBackend.ts # codex app-server protocol
ClaudeAcpBackend.ts # ACP protocol (@zed-industries/claude-agent-acp)
config.ts # config file + env loading, selection resolver, model catalog
server.ts # WebSocket bridge (backend-agnostic)
index.ts # entrypoint
gateway.config.example.yaml # annotated example config (see above)
- One long-lived child process per session (not spawn-per-turn).
- Each backend keeps a bounded stderr ring buffer for diagnostics and emits
turn_errorwithshouldRetire: trueif the subprocess dies mid-turn. - No persistence, no auth, no reconnection/resume — sessions live only as long as the WebSocket connection and the process stays up.
{ "type": "start", "backend": "codex" | "claude", "cwd": "/path/to/project", "model": "...", "provider": "...", "alias": "..." } { "type": "user_message", "sessionId": "...", "text": "..." } { "type": "approval_response", "requestId": "...", "decision": "allow_once" | "allow_session" | "deny" } { "type": "cancel", "sessionId": "..." } { "type": "list_models" }