Skip to content

About

No description, website, or topics provided.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Latest commit

 

History

6 Commits

Folders and files

Repository files navigation

agent-chat-gateway

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, no jsonrpc envelope).
  • 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.

Prerequisites

Auth is out of scope for this project — do this first, once, outside of this repo:

  • codex login — the codex CLI 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).

Setup

npm install

Run

npm run dev     # tsx watch — auto-restarts on file changes
# or
npm run build && npm start

The server listens on ws://localhost:8787 by default (override with the PORT env var).

Model/provider selection & config

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, or gateway.config.json in the working directory, or a path given via the GATEWAY_CONFIG env var. No file is required — with none present, a session must specify a backend explicitly (or rely on GATEWAY_DEFAULT_BACKEND / GATEWAY_DEFAULT_MODEL).
  • GATEWAY_DEFAULT_BACKEND and GATEWAY_DEFAULT_MODEL env vars, when set, override the config file's defaults.
  • See gateway.config.example.yaml for a full annotated example, including defaults, named aliases (a single shortcut resolving to a full backend/model/provider selection), per-backend models allowlists, and codex providers (with optional config merged into the resolved selection's backendConfig, forwarded to codex's thread/start).
  • provider is codex-only. Selecting a provider for the claude backend 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.

Wire protocol (gateway <-> chat UI)

Client -> server messages:

{ "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" }

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.

Project layout

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)

Notes / limitations

  • One long-lived child process per session (not spawn-per-turn).
  • Each backend keeps a bounded stderr ring buffer for diagnostics and emits turn_error with shouldRetire: true if 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.

About

No description, website, or topics provided.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages