Architecture overview
This page is the system-design view of ContextRelay: the processes that run, how a message travels from one agent to the other, where the durable state lives on disk, and where the trust boundaries sit. If you only want the elevator pitch, read What is ContextRelay first; this page goes a level deeper, for readers who want the internals.
The mental model in one sentence: a loopback-only daemon owns one shared append-only ledger, and every agent talks to that daemon - never directly to the other agent.
The big picture
ContextRelay runs entirely on your workstation. There is no cloud component and no inbound network surface. The pieces are:
┌──────────────── ───┐ ┌───────────────────┐
│ Claude Code │ │ Codex (TUI) │
│ + CR plugin │ │ │
└─────────┬─────────┘ └─────────┬─────────┘
│ MCP │ app-server WS
┌─────────┴─────────┐ ┌─────────┴─────────┐
│ Bridge │ │ Codex proxy │
│ (plugin process) │ │ (inside daemon) │
└─────────┬─────────┘ └─────────┬─────────┘
│ control WebSocket (127.0.0.1) │
└───────────────┬─────────────────────────┘
│
┌─────────┴─────────┐
│ Daemon │ single authority for:
│ (control server) │ - ledger writes
│ 127.0.0.1 only │ - routing & policy
└─────────┬─────────┘ - task board & finality
│
┌─────────────────┼──────────────────┐
│ │ │
.contextrelay/ queue.db viewer + /metrics
sessions/*.jsonl (SQLite, (read-only HTTP,
(the ledger) Claude-bound) local token)
Everything between the agents passes through the daemon. That is what makes the session auditable: there is exactly one writer of record, and it appends every message, handoff, note, decision, and artifact to the ledger.
ContextRelay orchestrates Claude Code and Codex; neither product orchestrates the other. They run as two separate, trusted local processes and exchange context only through the shared daemon and ledger - they cannot see each other's hidden reasoning.
Components
Daemon - the control server (src/daemon.ts)
The daemon is the loopback-only control server at the center of every
ContextRelay project. It binds to 127.0.0.1 (hostname 127.0.0.1, never a
public interface) on a project-scoped port group - by default the daemon control
port is 4502 - and it is the single point of authority for:
- Ledger writes. All durable state is appended through the daemon.
- Routing. It relays messages between the Claude bridge and the Codex proxy.
- Policy enforcement. Permissions, the coordinator/git-write role, and autonomy gates are evaluated here.
- Derived state. The task board, handoff state, and finality state are derived from the ledger by the daemon.
- Scheduling. Optional idle-scanner and read-only backup workers are scheduled by the daemon (off by default - see Autonomy, idle scanner, and safe automation).
- Lifecycle. It owns graceful shutdown and idle-shutdown.
Because the daemon is the only writer, recovery and audit have a single source of truth. If a process crashes, the ledger still reflects what happened.
Bridge - Claude's plugin entry point (src/bridge.ts)
The bridge is the ContextRelay Claude Code plugin's entry point, and it runs as a separate long-lived process from Claude Code itself. It connects Claude's plugin to the daemon over a control WebSocket, routes Claude's MCP tool calls to the daemon, and delivers Codex's messages to Claude.
The bridge is deliberately defensive. It detects daemon disconnects, control
protocol version skew, and stale sessions, and it latches a kill/disabled
state rather than thrashing: once disabled it only auto-recovers when a healthy
daemon reappears in the same instance and state directory. When ContextRelay is
launched as a read-only worker (CONTEXTRELAY_WORKER=1), the bridge skips
autostart entirely so workers never recursively spin up ContextRelay.
Adapters - protocol shims (src/claude-adapter.ts, src/codex-adapter.ts)
The adapters wrap the two very different agent protocols behind a common connection-status interface:
ClaudeAdapterwraps Claude's MCP plugin channel: it issues tool calls, pushes Codex notifications to Claude, and tracks the active delivery mode.CodexAdapterspawnscodex app-serveron a WebSocket port and runs a proxy on a second port. The Codex TUI connects to the proxy; the daemon forwards everything through it. The app-server connection is persistent - it is not torn down when the TUI reconnects - and the adapter runs health probes and handles reconnects.
Delivery: push by default, pull as fallback
Claude can receive Codex messages two ways, and the adapter tracks which one is live:
- Push - the daemon pushes a Codex message to Claude's plugin as a
<channel source="contextrelay" …>notification. This is the preferred mode. - Pull - Claude drains queued messages with the
get_messagesandwait_for_messagesMCP tools. This is the fallback.
The resolved mode is auto by default: the adapter starts in pull pending
client-capability detection, then promotes to push if the plugin channel is
supported. You can force a mode with the CONTEXTRELAY_MODE environment
variable (push | pull | auto). Codex always bridges through the
app-server proxy.
Push notifications can fail or lag. Rather than lose a message, the daemon keeps
Claude-bound Codex messages in a durable SQLite queue (queue.db) so Claude can
pull them when push is unavailable. Delivery degrades gracefully instead of
dropping.
Viewer and metrics (src/viewer.ts, src/metrics.ts)
ctxrelay viewer serves a read-only local Command Deck over the same
loopback control port, authenticated by a local viewer token. It shows
connection health, agent idle/busy/stale/offline state, task lanes, artifacts,
policy, and a latest-first timeline. It cannot send agent work, approve
finality, or mutate git. The one mutation it allows is clearing the current
session's viewer timeline through its authenticated history endpoint.
Prometheus-style metrics are exposed at /metrics (also behind the local
token), including series such as contextrelay_ledger_writes_total,
contextrelay_codex_messages_total, and contextrelay_idle_opportunities_detected_total.
# Scrape metrics for the current project
TOKEN=$(cat "$(ctxrelay status --json | jq -r .stateDir)/token")
PORT=$(ctxrelay status --json | jq -r .controlPort)
curl -H "Authorization: Bearer $TOKEN" "http://127.0.0.1:$PORT/metrics"
Data flow: how a message travels
Trace a single Codex-to-Claude reply through the system:
- Codex calls an MCP tool (for example
send_to_claudeorhandoff_to_claude), which reaches the daemon through the Codex proxy. - The daemon appends the message to the ledger (the durable record), then decides delivery.
- In push mode, the daemon pushes the message to the bridge, which hands it to Claude as a channel notification.
- In pull/fallback mode, the daemon enqueues the message in
queue.db; Claude later drains it withget_messages/wait_for_messages. - Claude reads context (
read_contextsurfaces recent entries plus the latest still-open handoff) and replies, which travels back the same way.
The Claude→Codex direction is symmetric: Claude's reply / handoff tool calls
go through the bridge to the daemon, get appended to the ledger, and are
delivered to Codex. The throughline is that the ledger append happens first,
inside the daemon, regardless of delivery mode.
On-disk state layout
All durable state is project-local, under .contextrelay/. The shared ledger is
a set of append-only JSONL files under sessions/, with a current pointer
naming the active session; the daemon's runtime state lives under state/:
.contextrelay/
├── config.json # project configuration (see config.json reference)
├── sessions.json
├── current # pointer to the active shared-session id
├── sessions/
│ └── session_<ts>_<rand>.jsonl # the append-only ledger (one file per session)
└── state/
├── daemon.pid
├── daemon-identity
├── daemon.lock
├── codex-tui.pid / codex-tui.json
├── codex-app-server.pid
├── status.json
├── ports.json
├── contextrelay.log
├── killed
├── token / proxy-token / viewer-token
├── queue.db # SQLite queue of Claude-bound Codex messages
├── transcript.jsonl # flat transcript mirror
├── activation/ # per-workspace attach markers (<key>.on)
└── runtime-sessions/
A separate global instance registry tracks which projects own which ports.
On macOS that lives at
~/Library/Application Support/ContextRelay/instances.json. The first project
uses the default port group; additional projects increment by 10.
4500 Codex app-server
4501 ContextRelay Codex proxy
4502 ContextRelay daemon control
If you override ports with environment variables, set CODEX_WS_PORT,
CODEX_PROXY_PORT, and CONTEXTRELAY_CONTROL_PORT together. Partial overrides
are rejected. See the
Environment variables reference.
Where the trust boundaries are
Two properties keep ContextRelay safe by construction:
- Loopback only. The daemon, bridge, viewer, and Codex proxy all bind to
127.0.0.1. There is no inbound network surface, and every local endpoint is gated by a per-project token. - Read-only by default, fail-closed for writes. Read-only backup-agent
autonomy is off by default, and autonomous edits (
act:write) are off by default behind layered gates. Whenact:writeis enabled, the worker is confined to an ephemeral git worktree on acontextrelay/write/branch, never the primary tree, and it never commits, merges, or pushes - the daemon captures the diff and tears the worktree down. Git writes are reserved for the single configured coordinator.
The full enumeration of assets, controls, non-controls, and failure modes lives in Trust boundaries and threat model.
Next steps
- The shared durable ledger - the append-only JSONL record at the heart of the system.
- Runtime sessions and worktrees - how named sessions and worktrees isolate parallel work.
- Trust boundaries and threat model - the assets, controls, and failure modes in detail.
- Read-only by default: safety and containment
- why autonomy and
act:writeare off until you explicitly opt in.
- why autonomy and