Architecture
The relay is a three-layer pipeline with a neutral model in the middle, so the wire format and the backend never touch each other directly.
flowchart LR
client(["Client"])
subgraph api["API layer — internal/api · internal/server"]
direction TB
decode["decode + auth<br/>+ rate limit"]
encode["SSE / JSON<br/>encode (EventSink)"]
end
subgraph core["Core — internal/core"]
dispatch["Dispatcher<br/>limiter · timeout · routing"]
end
subgraph backend["Backend — internal/backend"]
cli["claude CLI subprocess<br/>· or · ollama HTTP"]
end
client -- "HTTP: Messages / Chat" --> decode
decode -- "InferRequest" --> dispatch
dispatch -- "Infer(ctx, req, sink)" --> cli
cli -- "stream-json → Event" --> encode
encode -- "SSE / JSON" --> client
- API layer (
internal/api/*,internal/server) — HTTP handlers plus wire encoders/decoders. Translates Anthropic Messages and OpenAI Chat Completions ⇄ the neutralInferRequest/Eventmodel. - Core (
internal/core) — auth-independent dispatch: concurrency limiter, per-request timeout, handoff to the selected backend. Knows nothing about any specific CLI or wire format. - Backend (
internal/backend/*) — one adapter per agent CLI. Spawns and supervises the subprocess and parses its output into neutralEvents. The only layer that knowsclaude.
Package layout
cmd/relay/main.go # env parse, Validate, wire up, ListenAndServe
internal/config/ # Config, loading, Validate (startup guards)
internal/core/ # Message, InferRequest, Event, Backend, registry, Limiter, Dispatcher
internal/server/ # http mux, middleware (auth, request-id), handlers
internal/api/anthropic/ # Messages wire <-> core (decode, SSE + collect sinks)
internal/api/openai/ # Chat Completions wire <-> core
internal/backend/claude/ # Claude adapter (spawn, stream-json parse, env sanitize)
internal/obs/ # request-id, structured logging, metrics
Dependency direction is strictly inward: api, server, and backend
depend on core; core depends on nothing in the module. core is
unit-testable in isolation.
The neutral model
core.InferRequest is the normalized request (logical model name, system
prompt, text-only messages, stream flag). Backends emit a flat stream of
core.Event values (MessageStart, TextDelta, Usage, MessageStop,
Error) into a core.EventSink, which each wire format implements once.
This seam is what makes new backends additive: a Gemini or Codex adapter is
one new package under internal/backend/ that calls core.Register from an
init() — zero changes to the API or core layers.
Request lifecycle
stateDiagram-v2
[*] --> Queued
Queued --> Full: pool full
Queued --> Starting: slot acquired
Starting --> Failed: spawn error
Starting --> Streaming: started
Streaming --> Done: stream complete
Streaming --> Cancelled: ctx cancelled / timeout
Full --> [*]: 503
Failed --> [*]: 502
Cancelled --> [*]: kill process group
Done --> [*]: release slot · reap
- Slot acquisition is non-blocking: a full pool yields 503 immediately, before any subprocess is spawned.
- Slot release is
defer-guaranteed on every exit path. - The request timeout is a
context.WithTimeoutwrapping the HTTP request context; cancellation (client disconnect or timeout) kills the whole subprocess process group, andInferdoes not return until the process is reaped. An expired deadline answers 504, not 502, so a client can tell a timeout from a backend failure.
The happy path, as a sequence:
sequenceDiagram
autonumber
participant C as Client
participant S as Server<br/>(auth · limiter)
participant D as Dispatcher
participant B as claude backend
C->>S: POST /v1/messages
S->>S: authenticate · acquire slot
S->>D: InferRequest
D->>B: Infer(ctx, req, sink)
B->>B: spawn CLI · prompt via stdin
loop stream-json lines
B-->>C: SSE event (message_start, deltas…)
end
B-->>C: message_stop (+ usage)
B->>D: return (process reaped)
D->>S: release slot (defer)
Claude backend specifics
- The prompt is piped via stdin (never argv — avoids
ARG_MAXand process-list leaks). - The subprocess environment is
os.Environ()minus a deny list:ANTHROPIC_BASE_URL/OPENAI_BASE_URL(would loop the CLI back through the relay),CLAUDECODE,ANTHROPIC_API_KEY/ANTHROPIC_AUTH_TOKEN(so an inherited key cannot override the subscription), and any operator-configured keys (RELAY_ENV_DENY). stream-jsonoutput is parsed defensively: unknown line types and unknown fields are ignored, so CLI schema drift degrades gracefully.- No permission-bypass flags are ever passed on the default inference path;
agentic flags are an explicit operator opt-in, and with per-request authz
enabled they apply only to requests that presented a valid agentic
credential (
InferRequest.Agentic, set by the server, re-checked by the backend). - In agentic mode, every request runs in its own ephemeral working
directory (created under
RELAY_CLAUDE_WORKDIR, or the system temp dir), removed once the subprocess is reaped — concurrent requests never see each other's files, and no state persists between requests.
The full comparison of the two execution modes — enforcement mechanisms, guarantees, and caveats — is in execution-modes.md.