Skip to main content

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.

Provider-neutral by design

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:

  • ClaudeAdapter wraps Claude's MCP plugin channel: it issues tool calls, pushes Codex notifications to Claude, and tracks the active delivery mode.
  • CodexAdapter spawns codex app-server on 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_messages and wait_for_messages MCP 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.

Why a fallback queue exists at all

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:

  1. Codex calls an MCP tool (for example send_to_claude or handoff_to_claude), which reaches the daemon through the Codex proxy.
  2. The daemon appends the message to the ledger (the durable record), then decides delivery.
  3. In push mode, the daemon pushes the message to the bridge, which hands it to Claude as a channel notification.
  4. In pull/fallback mode, the daemon enqueues the message in queue.db; Claude later drains it with get_messages / wait_for_messages.
  5. Claude reads context (read_context surfaces 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
Set all three ports or none

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. When act:write is enabled, the worker is confined to an ephemeral git worktree on a contextrelay/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