mcp-agent-relay
README.md
# mcp-agent-relay
**Durable MCP dispatch for explicit CLI executors.**
`mcp-agent-relay` lets one MCP client hand a job to a locally installed CLI agent and retrieve a durable result. It keeps the queue, state, leases, cancellation, and review policy in one MCP-native relay while delegating each turn to a small, allowlisted executor adapter.
It is designed for a practical local workflow: Codex can request an independent read-only review from Codex, Claude Opus, or Claude Fable without turning the relay into a remote shell.
> Status: **research preview / single-machine relay.** The durable queue and executor adapters are production-shaped; wake-up integration with Claude Code depends on Claude Code preview capabilities.
## Why use it?
The hard part of agent-to-agent work is not starting a command. It is retaining a correct answer when processes restart, jobs collide, a worker loses its lease, or a caller gives up waiting.
The relay provides:
- **Durable dispatch.** File-backed jobs survive server and worker restarts. A `request_id` makes dispatch idempotent.
- **Explicit routing.** The `to` field selects one allowlisted worker id; it cannot select a binary, arguments, Claude agent, or environment.
- **Safe execution defaults.** Jobs are read-only by default. Codex writes require an explicit worker opt-in; Claude writes are rejected in this release.
- **Correct worker coordination.** Workers claim jobs using leases, fencing tokens, heartbeats, cancellation, timeout handling, and recovery states.
- **MCP-first integration.** Any MCP client can `dispatch`, `poll`, `dispatch_wait`, inspect `audit`, read a bounded `stream` or hash-verified `changelog`, create/dispatch an approved `handoff`, or diagnose with `trace`/`health`. A running Claude Code session can also be notified when a job changes state.
- **Operational visibility.** Every turn has a bounded deadline, redacted lifecycle events, worker heartbeat evidence, and a passive monitor for diagnosing Codex/Claude jobs without redispatching them.
- **Auditable executor profiles.** Requested model policy, effective-model evidence, CLI version, timestamps, and exit status are recorded without accepting model selection from the payload.
## At a glance
| Need | Use |
| --- | --- |
| Quick read-only review with Codex | `to: "codex"` |
| Deep Claude review with the local `deep-reasoner` agent | `to: "claude-opus"` |
| Independent Claude review with the local `fable-reasoner` agent | `to: "claude-fable"` |
| Verified three-family review from Codex or Claude Code | `/tri-r` after installing the bundled skill |
| Two-lens cross-harness review with Sol/xhigh and Opus/max | `/dual-r` after installing the bundled skill |
| Wait for one result in the tool call | `dispatch_wait` |
| Submit now and inspect later | `dispatch` then `poll` |
| Inspect executor/model evidence after result retention | `audit` |
| Inspect one job's redacted lifecycle | `trace` |
| Inspect project provenance and handoff history | `changelog` |
| Inspect relay and worker health | `health` or `bin/relay-monitor.mjs` |
| Make a Codex change in an isolated worktree | `write: true, worktree: true` and a Codex worker started with `--allow-writes` |
## Architecture
~~~
MCP client durable relay worker
────────── ───────────── ──────
dispatch(to, task, request_id) ──▶ queue + request-id dedup
claim + lease + fencing ───────────▶ adapter.runTurn()
poll(job_id) ◀─── result + timing evidence ◀───────── Codex or Claude CLI
trace(job_id) ◀─── redacted process/state timeline
health() ◀─── server + worker heartbeat evidence
audit(job_id) ◀─── compact terminal ledger
~~~
| Layer | Responsibility |
| --- | --- |
| `lib/relay-jobs.mjs` | Durable queue, locking, deduplication, leases, fencing, turn deadlines, expiry, cancellation, recovery, review state, trace events, and compact audit ledger |
| `server.mjs` | MCP stdio facade, inbox resources, synchronous wait, and optional Claude channel |
| `lib/relay-worker.mjs` | Claim loop, heartbeat, timeout/deadline fencing, cancellation, process tracing, worktree orchestration, and durable completion |
| `lib/worker-lifecycle.mjs` | Worker autospawn/reuse, build identity, PID/heartbeat health, and safe replacement of stale daemons |
| `bin/relay-monitor.mjs` | Passive JSON/text monitor for jobs and Codex/Claude worker health |
| `bin/relay-debug.mjs` | One-shot, read-only correlation of MCP registration, workspace store, workers, and queued jobs |
| `bin/relay-gc.mjs` | Operator-triggered bounded retention for Context Store streams, summaries, handoffs, and locks |
| `bin/relay-cross-host-test.mjs` | Cross-host contract check for CLIs, MCP, skills, profiles, monitor/debug, and the suite |
| `bin/relay-studio-self-test.mjs` | Deterministic A → approved handoff → B contract test without starting a CLI |
| `bin/relay-studio-mvp0-smoke.mjs` | Real read-only MVP-0 smoke: workspace A → approved handoff → workspace B |
| `lib/executor-registry.mjs` | Central allowlist from worker id to an adapter and its fixed configuration |
| `lib/codex-executor.mjs` / `lib/claude-executor.mjs` | One isolated CLI turn with final-result compatibility, normalized stream hooks, and process-group shutdown |
| `lib/relay-streams.mjs` | Bounded, redacted per-attempt stream spool and passive tail reader; never queue state |
| `lib/relay-errors.mjs` | Redaction and 8 KiB cap for executor errors crossing into durable state or MCP responses |
| `lib/relay-context.mjs` / `lib/relay-changelog.mjs` | Deterministic run summaries and redacted, hash-verified project changelog entries |
| `lib/relay-handoffs.mjs` | Hash-verified, approval-gated context manifests between allowlisted workspaces/executors |
The executor boundary is deliberately narrow: the relay is **executor-agnostic**, not **command-agnostic**.
## Host-aware `/tri-r`
The bundled [`tri-r` skill](skills/tri-r/SKILL.md) always reviews with three distinct
model families: Opus, Fable, and GPT-5.6 Sol. The logical panel is the same in both
harnesses, while the native and relayed routes are inverted:
The complete cross-harness capability matrix is maintained in
[`docs/review-capability-matrix.md`](docs/review-capability-matrix.md).
| Orchestrator host | Opus lens | Fable lens | Sol lens |
| --- | --- | --- | --- |
| Claude Code | Native `deep-reasoner` | Native `fable-reasoner` | AgentRelay `to: "codex"` |
| Codex | AgentRelay `to: "claude-opus"` | AgentRelay `to: "claude-fable"` | Fresh native `pipeline_deep_reviewer`; fallback AgentRelay `to: "codex"` |
The Codex route never treats `deep-reasoner.toml` or `fable-reasoner.toml` as Claude
models. Agent names are prompts and routing labels, not model evidence. Every relayed
lens records `requestedModelPolicy`, `effectiveModel`, and `modelEvidence` when the
executor supplies them.
Install or refresh the same canonical skill in the personal discovery directories of
both harnesses:
~~~bash
node bin/relay-install-tri-r.mjs
~~~
This writes `~/.agents/skills/tri-r/SKILL.md` for Codex and
`~/.claude/skills/tri-r/SKILL.md` for Claude Code. The operation is idempotent. Use
`--codex-only`, `--claude-only`, or `--home /alternate/home` when needed. Restart open
Codex and Claude Code sessions after updating a skill because skill catalogs are loaded
when a session starts.
`/tri-r` is intentionally expensive and produces a consolidated, verified panel. For a
single-family review, ask the MCP client to use `claude-opus`, `claude-fable`, or
`codex` directly instead of running a partial `tri-r`.
A `dispatch_wait` timeout is not a failed review. It only ends the MCP caller's bounded
wait; the durable job continues. `/tri-r` records that lens as `PENDING`, preserves its
`job_id`, and does not consolidate the panel. After a terminal notification it performs
one targeted `poll` and resumes the same job without redispatching or changing the
`request_id`. Only terminal `failed`, `cancelled`, or `expired` states degrade a lens.
## Host-aware `/dual-r`
[`dual-r`](skills/dual-r/SKILL.md) is a smaller two-lens panel with inverted routing:
| Orchestrator | Native lens | AgentRelay lens |
| --- | --- | --- |
| Codex | `dual_sol_reviewer` — GPT-5.6 Sol, `xhigh`, read-only | `claude-opus-max` — `deep-reasoner`, Opus alias, `max` |
| Claude Code | `dual-opus-reasoner` — Opus alias, `max`, read-only | `codex-sol-xhigh` — GPT-5.6 Sol, `xhigh`, read-only |
Install the same skill and the native profiles in both personal harnesses:
~~~bash
node bin/relay-install-dual-r.mjs
~~~
The installer is idempotent. It writes `~/.agents/skills/dual-r/SKILL.md`,
`~/.codex/agents/dual_sol_reviewer.toml`, `~/.claude/skills/dual-r/SKILL.md`, and
`~/.claude/agents/dual-opus-reasoner.md`. It does **not** edit
`~/.codex/config.toml`: register the Codex profile once if needed:
~~~toml
[agents.dual_sol_reviewer]
description = "Independent read-only Sol xhigh reviewer for dual-r"
config_file = "./agents/dual_sol_reviewer.toml"
~~~
Add `codex-sol-xhigh` and `claude-opus-max` to `RELAY_WORKER_AGENTS` in the MCP
registration. Restart Codex and Claude Code sessions after installing skills; their
catalogs are loaded at session start. The durable wait rules are the same as `tri-r`:
dispatch once, optionally one bounded `dispatch_wait`, preserve `PENDENTE` on caller
timeout, and make one `poll` only after terminal notification.
The relay uses Claude's `opus` latest-model alias, not an invented numbered model id.
Use the returned `effectiveModel`/`modelEvidence` (or report the gap) before calling it
“Opus 5”. Both lenses are read-only; `write: true` is rejected before a process starts.
## Available executors
| Worker id / `to` | Adapter | Fixed model policy | Turn ceiling | Policy kind | Write policy |
| --- | --- | --- | --- | --- | --- |
| `codex` | Codex CLI | `gpt-5.6-sol` / `high` | 10 min | Pinned profile | Denied unless that worker has `--allow-writes` |
| `codex-sol-xhigh` | Codex CLI | `gpt-5.6-sol` / `xhigh` | 30 min | Pinned dual-r profile | Always rejected |
| `claude-opus` | Claude Code | `deep-reasoner` + `--model opus` | 10 min | Latest Opus alias | Always rejected |
| `claude-opus-max` | Claude Code | `deep-reasoner` + `--model opus` + `--effort max` | 10 min | Latest Opus alias | Always rejected |
| `claude-fable` | Claude Code | `fable-reasoner` + `--model claude-fable-5` | 10 min | Pinned model id | Always rejected |
For the Claude routes, the local Claude Code installation must already be authenticated and must have the corresponding agent definitions:
~~~
~/.claude/agents/deep-reasoner
~/.claude/agents/dual-opus-reasoner
~/.claude/agents/fable-reasoner
~~~
Claude runs in non-interactive `stream-json` mode. The initial structured event is the evidence source for the effective model, and the terminal structured event carries the final response and session id. The `--` delimiter ensures a prompt cannot be parsed as a Claude CLI option.
Relay Claude turns are isolated from ambient Claude configuration: the adapter
loads only the `local` settings source (an empty `--setting-sources` would mean
the defaults), runs from a private temporary cwd and grants the target workspace
only through `--add-dir`, passes a fixed empty `--mcp-config` with
`--strict-mcp-config`, and supplies the allowlisted agent definition inline. It
disables Chrome, slash skills and session persistence,
and allows only the built-in `Read`, `Grep` and `Glob` tools. This prevents a review
from recursively starting AgentRelay, Backlog or browser MCP servers inherited from
the user's project. OAuth/keychain authentication remains available through the
isolated process's `HOME`; credential environment variables are deliberately not
inherited. The relay never installs credentials or accepts environment/configuration
from a payload.
`claude-opus` deliberately means the CLI's `opus` latest-model alias; it is not a claim that a specific numbered Opus release ran. `claude-fable` is currently pinned to the full `claude-fable-5` model id. Payload fields such as `command`, `args`, `agent`, `model`, and environment settings cannot alter either profile.
## Quick start: connect Codex
### 1. Clone the relay and verify Node
~~~bash
git clone https://github.com/brenoperucchi/mcp-agent-relay.git
cd mcp-agent-relay
node --version # Node 18.18 or newer
~~~
No package installation or build step is required.
### 2. Register the MCP server
Register a global Codex MCP server, or put the equivalent configuration in a trusted project if it should be project-scoped:
~~~bash
codex mcp add agentrelay \
--env RELAY_WORKER_AUTOSPAWN=1 \
--env RELAY_WORKER_AGENTS=codex,codex-sol-xhigh,claude-opus,claude-opus-max,claude-fable \
-- node /absolute/path/to/mcp-agent-relay/server.mjs
~~~
If a CLI is installed outside the inherited environment, provide a minimal explicit `PATH` for the relay process:
~~~bash
codex mcp add agentrelay \
--env PATH=/home/you/.local/bin:/usr/local/bin:/usr/bin:/bin \
--env RELAY_WORKER_AUTOSPAWN=1 \
--env RELAY_WORKER_AGENTS=codex,codex-sol-xhigh,claude-opus,claude-opus-max,claude-fable \
-- node /absolute/path/to/mcp-agent-relay/server.mjs
~~~
Confirm the registration:
~~~bash
codex mcp list
~~~
When the server receives a job, autospawn starts one short-lived worker per configured executor id as needed. You can instead run workers yourself; see [Running workers](#running-workers).
### 3. Dispatch a read-only review
In a Codex session, call `dispatch_wait` with an explicit executor id:
~~~json
{
"to": "claude-opus",
"task": {
"prompt": "Review the current diff for correctness, regressions, and missing tests. Report only actionable findings."
},
"request_id": "review-current-diff-opus-1",
"timeout_ms": 120000
}
~~~
For the Fable-backed Claude agent:
~~~json
{
"to": "claude-fable",
"task": {
"prompt": "Independently review the current diff. Focus on security and reliability risks."
},
"request_id": "review-current-diff-fable-1",
"timeout_ms": 120000
}
~~~
For the Opus/max lens used by `/dual-r` from Codex:
~~~json
{
"to": "claude-opus-max",
"task": {
"prompt": "Independently review the current diff. Read-only; report actionable findings with file/line evidence."
},
"request_id": "review-current-diff-opus-max-1",
"timeout_ms": 120000
}
~~~
From Claude Code, the relayed Sol/xhigh lens uses the inverse route:
~~~json
{
"to": "codex-sol-xhigh",
"task": {
"prompt": "Independently review the current diff. Read-only; focus on implementation edges and missing tests."
},
"request_id": "review-current-diff-sol-xhigh-1",
"timeout_ms": 120000
}
~~~
If `claude` is missing, not on `PATH`, unauthenticated, or its named local agent is unavailable, the job reaches a clear failed state with the CLI error. The relay never installs Claude, changes global settings, or creates credentials.
## MCP tools and job lifecycle
### Submit now, retrieve later
`dispatch` creates (or deduplicates) a durable job and returns immediately:
~~~json
// dispatch
{
"to": "codex",
"task": { "prompt": "Review the current diff for correctness." },
"request_id": "review-current-diff-codex-1"
}
// response
{ "job_id": "relay-…", "deduped": false, "state": "queued" }
~~~
Call `poll` until it reaches a terminal state:
~~~json
// poll
{ "job_id": "relay-…" }
// response
{
"found": true,
"state": "completed",
"result": { "output": "…" },
"attempts": 1,
"execution": {
"executorId": "claude-opus",
"requestedModelPolicy": "opus",
"modelPolicyKind": "latest_alias",
"effectiveModel": "claude-opus-…",
"modelEvidence": "claude-stream-json:init",
"cliVersion": "…",
"exitCode": 0
}
}
~~~
The same `request_id` returns the same job rather than scheduling the work twice.
If a later request reuses that ID with a different target executor or payload, the
relay returns `request_id_conflict` instead of silently routing the new request to
the old job.
### Submit and wait
`dispatch_wait` follows the same idempotent path but waits for a terminal result, up to `timeout_ms`:
~~~json
{
"to": "codex",
"task": { "prompt": "Review the current diff for correctness." },
"request_id": "review-current-diff-codex-2",
"timeout_ms": 120000
}
~~~
If the caller timeout expires first, the result says `timed_out: true` and reports the current queued or running state. The job continues server-side; retrieve it later with `poll`.
### Audit executor and model evidence
`poll` and `dispatch_wait` expose `execution` while the job is retained. The `audit` tool keeps a compact terminal history independently of normal job/result retention:
~~~json
// audit
{ "job_id": "relay-…" }
// response
{
"found": true,
"job_id": "relay-…",
"records": [
{
"state": "completed",
"to": "claude-opus",
"execution": {
"requestedModelPolicy": "opus",
"modelPolicyKind": "latest_alias",
"effectiveModel": "claude-opus-…",
"modelEvidence": "claude-stream-json:init"
},
"resultSha256": "…"
}
]
}
~~~
The audit ledger stores no prompt, raw result, raw error, working-directory path, or request id. It stores hashes for correlation plus allowlisted execution metadata. It is bounded to the latest 5,000 terminal records per workspace. Existing version-1 stores migrate automatically with an empty audit history.
Executor failures are redacted and capped at 8 KiB before entering the durable job
state; the full stderr remains available only through the separately redacted,
quota-bound stream spool.
`requestedModelPolicy` says what the registry requested. `effectiveModel` is populated only when the CLI provides structured evidence; otherwise it remains `null`. In particular, passing `-m gpt-5.6-sol` proves the Codex policy requested by the relay, not a private backend rollout identifier.
### Debugging and monitoring a live relay
The relay has two non-mutating diagnostics for investigating a request from either
Codex or Claude Code:
~~~json
// trace
{ "job_id": "relay-…", "limit": 200 }
// health
{ "agents": ["codex", "codex-sol-xhigh", "claude-opus", "claude-opus-max", "claude-fable"] }
~~~
`trace` returns the bounded lifecycle (`claimed`, `running`, process spawn/close,
abort, and terminal transition) plus timing and worker metadata. `health` returns the
MCP server instance/build id, workspace store id, worker PIDs, heartbeat age, and build
mismatch status. Neither tool returns a prompt, result, claim token, or environment
variable.
### Reading a workspace stream
`stream` reads only the bounded tail emitted by the executor adapter:
~~~json
{ "job_id": "relay-…", "limit": 100 }
~~~
Events carry sequence, timestamp, channel (`stdout`, `stderr` or
`provider_event`), adapter, attempt and redaction metadata. They are stored in
per-attempt `*.stream.jsonl` files outside `relay-state.json`. This is a
structured process stream, not a scrape of terminal pixels or another window's
screen buffer. PTY capture remains a later capability for explicitly interactive
adapters. A completed turn also gets a deterministic `run_summary` in the
separate Context Store: it contains hashes, counts, model evidence, truncation
and provenance, never the full prompt or response.
### Changelog e proveniência
O Context Store também deriva uma entrada de changelog por execução terminal e por
handoff. A entrada contém apenas estado, timestamps, hashes, referências de artefatos
e a cadeia `sourceJobId → handoffId → targetJobId`; prompts, respostas, transcript e
variáveis de ambiente não entram no arquivo. A operação MCP é somente leitura:
~~~json
{ "limit": 100 }
~~~
O comando `changelog` devolve essas entradas com `entryHash`, e o monitor inclui o
mesmo resumo no snapshot JSON. Assim, a passagem A → B pode ser correlacionada sem
copiar o buffer de uma tela ou confiar em texto não verificado. Se a entrada for
adulterada, ela é omitida até ser regenerada a partir do `run_summary`/manifesto.
### Handoff entre workspaces
Um handoff nunca copia uma tela inteira nem aceita um comando do texto de uma
resposta. O workspace de origem cria um manifesto em rascunho, que só se torna
aprovado após uma operação explícita, com resumo,
referências de artefatos/faixas de eventos, IDs de origem/destino e hash de
contexto:
~~~json
{
"source_job_id": "relay-source…",
"target_workspace_id": "<store-id-do-destino>",
"target_agent_id": "claude-opus",
"summary": "Contexto sugerido para a próxima revisão.",
"created_by": "llm"
}
~~~
Depois que um operador local registrar a aprovação humana, no workspace de destino:
~~~bash
node bin/relay-handoff-approve.mjs handoff-… --by "reviewer"
~~~
~~~json
{ "handoff_id": "handoff-…", "request_id": "handoff-destination-1" }
~~~
Isso chama `handoff_dispatch`. O executor é lido do manifesto hash-verificado e
continua sujeito à allowlist, às políticas de escrita e ao lease normal. Um
handoff de job que não terminou em `completed`, com agente desconhecido,
manifesto adulterado ou caminho absoluto de artefato é recusado.
O worker reconstrói o contexto usando o `run_summary` hash-verificado e registra
`handoff_id`, job de origem e workspace de origem nos eventos e no resultado do
destino.
O endpoint MCP `handoff_approve` permanece fail-closed por compatibilidade, mas
nunca aceita uma identidade declarada pelo próprio modelo. `target_workspace_id`, `artifactRefs` e
`eventRanges` são referências opacas limitadas e não substituem um transporte ou
catálogo de artefatos entre hosts. Essa autoridade externa fica para a fase de
multi-host.
For a longer observation window, use the passive monitor in a separate terminal:
~~~bash
node bin/relay-monitor.mjs --workspace . \
--agents codex,codex-sol-xhigh,claude-opus,claude-opus-max,claude-fable --follow --interval 1000
# Machine-readable snapshots for a log collector:
node bin/relay-monitor.mjs --workspace . --follow --json
~~~
The monitor reports `queued`, `running`, `completed`, `failed`, `needs_recovery`, and
`stale`. It never dispatches, retries, cancels, or changes a payload. A `stale` job is a
diagnostic signal; the durable store's deadline fence is what transitions an expired
read-only turn to `failed` (or a write turn to `needs_recovery`).
The JSON snapshot also exposes bounded changelog metadata and handoff provenance;
the text mode appends a `handoff=<id>` marker for destination jobs.
Derived files are pruned opportunistically after terminal summaries and can also be
collected explicitly without touching queue state:
~~~bash
node bin/relay-gc.mjs --workspace . --max-files 5000 --max-bytes 536870912
~~~
Active jobs and draft/approved handoffs are retained. Workspace-local GC only
considers manifests owned by that workspace; unknown/foreign manifests and all
handoff locks are retained because an mtime is not proof that a lock is idle.
Dispatched/old derived files are eligible once the count or byte budget is exceeded.
This command is safe to run from a scheduled maintenance job on each host.
When a smoke test remains `queued` with `attempts: 0`, run the one-shot correlator before
dispatching again:
~~~bash
node bin/relay-debug.mjs \
--workspace ~/Devs/mcp-agent-relay \
--agents codex-sol-xhigh,claude-opus-max \
--job-id relay-…
~~~
The command reports `RELAY_WORKER_AGENTS`/`RELAY_WORKER_AUTOSPAWN` from the shell and
the Codex registration (`~/.codex/config.toml` by default) as separate sources; it does
not pretend that a standalone shell has the exact environment of an already-running MCP
process. It reports worker heartbeat/PID state and scans the current `RELAY_DATA_DIR`
(or the default state root) for the job id. It prints only metadata—never task prompts,
results, or raw error text—and exits with status `2` when a P0/P1 operational finding is
present. Typical findings are:
- `mcp_config_unavailable`: the default Codex registration is missing or unreadable and
no explicit relay worker environment was supplied;
- `worker_not_configured`: the MCP server can enqueue that `to`, but autospawn has no
worker configured to claim it;
- `queued_without_worker`: the job is still queued with zero attempts and no live worker;
- `configuration_divergence`: the shell environment and persisted MCP registration
disagree; inspect both before changing either one;
- `store_mismatch`: the job exists in another workspace store, so the monitor is watching
the wrong `--workspace` under the same state root. If the server uses another
`RELAY_DATA_DIR`, run the diagnostic with that same variable explicitly.
The diagnostic is intentionally passive. After correcting the registration, restart the
Codex/Claude session so its MCP environment is reloaded, then use `relay-monitor` with
the same workspace and `RELAY_DATA_DIR` to follow the new job.
### Bounded logging with journald/systemd
`relay-monitor` is useful interactively, but it is not a daemon. For a bounded
observation window, use `relay-observe`. It samples the selected workspace and writes
redacted JSONL only when a job/worker is active or changes. The record includes job
state transitions as seen by the store, worker liveness, executor/model evidence,
elapsed/deadline fields, and a coarse `error_code` (`timeout`, `cancelled`,
`lease_expired`, `write_policy`, or `executor_error`). It never writes prompts,
results, raw errors, environment variables, or worker log paths. Historical terminal
records remain available through `audit`/`trace` and are not replayed on collector
startup.
One snapshot:
~~~bash
node bin/relay-observe.mjs \
--workspace "$(git rev-parse --show-toplevel)" \
--agents codex,codex-sol-xhigh,claude-opus,claude-opus-max,claude-fable \
--once
~~~
To avoid choosing the wrong project store, use `--all-stores`. This scans every
workspace under the active `RELAY_DATA_DIR` (or `~/.mcp-agent-relay/state`) and is the
recommended mode for the systemd service:
~~~bash
node bin/relay-observe.mjs --all-stores --duration-ms 7200000 --interval 1000
~~~
Observe for two hours to stdout (which can be piped to `rsyslog` or `jq`):
~~~bash
node bin/relay-observe.mjs \
--workspace "$(git rev-parse --show-toplevel)" \
--duration-ms 7200000 \
--interval 1000 \
--output /tmp/mcp-agent-relay-observations.jsonl
~~~
For a user-level systemd collector, install the unit on each host where a Claude or
Codex session dispatches jobs:
~~~bash
mkdir -p ~/.config/systemd/user ~/.config/mcp-agent-relay
cp systemd/mcp-agent-relay-observer.service ~/.config/systemd/user/
cp systemd/observer.env.example ~/.config/mcp-agent-relay/observer.env
# The example uses RELAY_OBSERVER_ALL_STORES=1, so no project path is required.
# Set it to 0 and edit RELAY_OBSERVER_WORKSPACE for one project only.
systemctl --user daemon-reload
systemctl --user enable --now mcp-agent-relay-observer.service
journalctl --user -u mcp-agent-relay-observer.service -f -o cat
~~~
The unit is deliberately bounded (`RuntimeMaxSec=2h`) and sends JSONL to journald. Stop
it early with `systemctl --user stop mcp-agent-relay-observer.service`; it never
redispatches or mutates jobs. Repeat the same installation in SSHWSL, using the path
of the repository and project on that machine. If that WSL instance does not run
systemd, use the CLI directly with `--output` and keep the terminal/process alive for
the requested window.
Every claim now carries a server-side turn deadline in addition to its lease. A worker
heartbeat can keep a healthy silent turn leased, but it cannot keep an expired executor
running forever. An old daemon is identified by its relay build id. A new MCP server
replaces an outdated daemon only while it has no active claim; if a turn is in flight,
the update is deferred so a build change cannot abort→release→reclaim that job mid-review.
### Job states
| State | Meaning |
| --- | --- |
| `queued` | Waiting for a worker |
| `running` | Claimed by one worker with an active lease |
| `completed` | Durable final result available |
| `failed` | The adapter or policy rejected the job |
| `cancelled` | Cancellation was accepted |
| `needs_recovery` | A write-capable run lost its lease; it is never silently re-executed |
| `needs_review` | A human decision is required before or after execution |
## Security model
The relay assumes task prompts are untrusted data. It does not treat them as a shell request.
- The central registry owns the executable, fixed CLI arguments, Claude agent/model policy, and the Codex `gpt-5.6-sol` / `high` plus dual-r `gpt-5.6-sol` / `xhigh` profiles.
- A Codex task may omit `model` and `effort`; if it supplies either, it must exactly match that fixed profile. A downgrade or arbitrary model selection fails before the CLI starts.
- Claude tasks may omit `model`; any conflicting payload model is rejected before the CLI starts. Agent, binary, arguments, and environment always come from the registry.
- The dual-r profiles also reject payload `effort` overrides; `codex-sol-xhigh` and `claude-opus-max` are fixed allowlist entries, not arbitrary command runners.
- A job can choose only an exact, known `to` id. Unknown ids fail safely.
- Claude adapters run with a reduced environment, an isolated `local` settings source,
and an empty MCP configuration; they do not load user/project hooks, plugins, MCP aliases, or
payload environment values. The registry still supplies the explicit agent/model.
- All Claude jobs are read-only in this version. `write: true` for either Claude worker is an explicit failure before a CLI process starts.
- Codex writes are deny-by-default and require both a `write: true` job and a worker launched with `--allow-writes`.
- Write jobs with an expired lease go to `needs_recovery` instead of being replayed.
- Worktree execution is available for eligible Codex writes, so the main worktree stays untouched.
- Wake-up notifications contain only a minimal job envelope, never untrusted prompt text or model output.
This protects the relay’s command-selection boundary. It does not make a prompt harmless to the model receiving it; write careful task prompts and inspect all results.
## Running workers
Autospawn is convenient for local MCP use, but explicit workers work the same queue and are useful for long-lived or supervised setups.
~~~bash
# Claim at most one queued job, execute it, then exit.
node worker.mjs --agent codex --once
# Keep a read-only Claude worker running.
node worker.mjs --agent claude-opus --interval 1000
node worker.mjs --agent claude-opus-max --interval 1000
node worker.mjs --agent codex-sol-xhigh --interval 1000
node worker.mjs --agent claude-fable --interval 1000
# Permit Codex write jobs (still requires task.write: true).
node worker.mjs --agent codex --allow-writes --interval 1000
# Stop after five minutes with no jobs processed.
node worker.mjs --agent codex --idle-timeout 300000
# Fail a read-only turn that still runs after ten minutes. A timed-out write
# job is preserved in needs_recovery instead of being replayed.
node worker.mjs --agent claude-opus --timeout 600000
~~~
Worker selection is always based on `--agent` and the registry. It is never taken from a job payload.
`dispatch_wait.timeout_ms` limits only the MCP caller's wait. The worker has a separate
per-turn budget. An explicit `--timeout` or `RELAY_WORKER_TIMEOUT_MS` override wins; otherwise
the allowlisted profile owns the budget (`600000` ms for normal profiles and `1800000` ms for
`codex-sol-xhigh`). The selected value is persisted as a fenced deadline in the claim; a
read-only turn that exhausts it fails terminally rather than being re-run, while a write turn
enters `needs_recovery`. Increasing a job's `ttl_ms` or the MCP caller's `timeout_ms` does not
silently change the worker profile's execution ceiling.
### Store location
By default, state is stored beneath:
~~~text
~/.mcp-agent-relay/state
~~~
Set `RELAY_DATA_DIR` to choose another durable local location. Every process that participates in the same relay—the MCP server, workers, and optional hooks—must use the same store location.
The version-2 store embeds a bounded `audit` ledger alongside the active/retained job collection so terminal transition and audit evidence are committed by the same atomic store write.
## Codex writes in isolated worktrees
Codex is the only executor that can write in this first release. To opt in, start the Codex worker with `--allow-writes` and request an isolated worktree:
~~~json
{
"to": "codex",
"task": {
"prompt": "Implement TASK-192 and add focused tests.",
"write": true,
"worktree": true
},
"request_id": "implement-task-192-1"
}
~~~
The relay creates a branch and git worktree based on the caller’s current `HEAD`. The result includes `worktree.path`, `worktree.branch`, and `worktree.baseSha` for manual review and merge. Nothing merges automatically.
If a write turn makes no change, its temporary worktree and branch are removed. If it fails after making changes, the worktree is preserved and its path is included in the error for manual recovery.
> A worktree starts from the last commit, not from uncommitted edits in the caller’s main worktree.
## Human review gate
Jobs can require a human decision rather than running or completing autonomously.
- Add a non-empty `requireReview` reason to a task to put it in `needs_review` before execution.
- An executor can self-flag an ambiguous or sensitive task with `RELAY_NEEDS_REVIEW: <reason>` in the final response; its partial result is retained for inspection.
- Resolve gates only from the local review CLI, never through MCP tools:
~~~bash
node bin/relay-review.mjs list
node bin/relay-review.mjs approve <jobId> --by "reviewer" --note "approved after inspection"
node bin/relay-review.mjs reject <jobId> --by "reviewer" --note "not safe to run"
~~~
Predeclared approval returns a job to `queued` so it can run. Approval of a self-flagged result accepts that captured result. Rejection marks the job `failed`.
The CLI gate prevents a normal MCP client from approving its own job through the tools it already holds. It is not a complete process-isolation boundary: a local process with shell access can invoke the review CLI. Stronger approval authority requires a separate credential or isolation boundary.
## Claude Code integration
The relay can be used from Claude Code either as a plugin or as a plain MCP server.
### Plugin install
~~~bash
claude plugin marketplace add <your-org>/mcp-agent-relay
claude plugin install mcp-agent-relay
~~~
The plugin declares `agentrelay` in [`.mcp.json`](.mcp.json), includes the host-aware
`tri-r` and `dual-r` skills, and includes relay slash commands. The supplied commands dispatch to
`codex`:
- `/mcp-agent-relay:review <path> [focus]` requests a read-only Codex review.
- `/mcp-agent-relay:implement <task>` requests an isolated Codex worktree run; it needs a Codex worker allowed to write.
### Plain Claude MCP install
~~~bash
claude mcp add --scope user agentrelay \
node /absolute/path/to/mcp-agent-relay/server.mjs \
-e RELAY_AGENT=claude-main \
-e RELAY_WORKER_AUTOSPAWN=1 \
-e RELAY_WORKER_AGENTS=codex,codex-sol-xhigh,claude-opus,claude-opus-max,claude-fable
~~~
Plugin and plain-server installations expose different MCP names:
| Installation | Tool prefix | Channel source |
| --- | --- | --- |
| Plugin | `mcp__plugin_mcp-agent-relay_agentrelay__` | `plugin:mcp-agent-relay@mcp-agent-relay` |
| Plain `claude mcp add` | `mcp__agentrelay__` | `server:agentrelay` |
### Optional wake-up channel
Set a logical `RELAY_AGENT` identity on the Claude session and launch it with its corresponding development channel:
~~~bash
# Plugin installation
RELAY_AGENT=claude-main claude \
--dangerously-load-development-channels plugin:mcp-agent-relay@mcp-agent-relay
# Plain MCP installation
RELAY_AGENT=claude-main claude \
--dangerously-load-development-channels server:agentrelay
~~~
The channel sends only a small `job_id` and `state` notification. Claude then uses `poll` to obtain the normal structured result. When `CLAUDE_CODE_SESSION_ID` is available, notifications are narrowed to the specific session that dispatched the job.
### Stop hook alternative
The channel is optional. A Stop hook checks the store as Claude is about to end a turn and gives it one more turn to poll a newly completed job:
~~~bash
# Add project settings. Use --global for ~/.claude/settings.json.
node bin/relay-install-hook.mjs
~~~
The helper is idempotent. Use `--print` to preview or `--remove` to undo it. The hook needs the same `RELAY_AGENT` and `RELAY_DATA_DIR` configuration as the MCP server. It fails open, so an internal hook error never blocks a Claude session from ending.
## Requirements and limitations
- Node.js **18.18 or newer**. The runtime has no npm dependencies.
- The `codex` worker needs the Codex CLI available on its `PATH`.
- Claude workers need an existing local, authenticated Claude Code CLI plus the allowlisted agent definitions. The relay does not provision either.
- Claude jobs are read-only only. There is no Claude write mode in this release.
- `claude-opus` tracks the Claude CLI `opus` alias. Use `execution.effectiveModel` or `audit`, not the route name, when you need to know what the CLI reported for one completed run.
- `claude-opus-max` uses that same latest alias with a fixed `--effort max`; it still does not prove a numbered Opus release without `effectiveModel` evidence.
- `effectiveModel` may be `null` when an executor does not provide structured evidence. The relay never turns a requested alias into a fabricated effective version.
- Handoffs are explicit and hash-verified, but human identity/authentication and a global workspace/artifact registry are outside this local-file MVP.
- The store is a local file-backed queue coordinated by an interprocess lock. It is designed for one machine, not a multi-host queue.
- Stream fencing is amortized in bounded batches (up to 64 KiB or 16 events) under the queue lock; this preserves lease correctness while limiting the time a slow filesystem can delay another claim or heartbeat.
- Handoff lock files are never unlinked by workspace-local GC. The lock protocol itself reclaims stale JSON locks only through an atomic rename after checking process identity; legacy or malformed locks fail closed and remain until an operator removes them because an old mtime is not proof that no process still holds them.
- After updating relay source, restart the MCP server/session that owns the relay. `health` and `dispatch` diagnostics now expose `source_build_id`/`build_mismatch`; the server captures a startup fingerprint for worker fencing, so a mismatch is an explicit restart signal rather than a silent stale session.
- The development channel is a Claude Code preview feature and may require the explicit channel flag. The queue, polling, and Stop hook remain usable without it.
## Development
~~~bash
node --test
~~~
The suite covers the store and MCP facade, worker lifecycle, review and worktree protections, Codex compatibility, executor-registry resolution, and mocked Claude CLI success, failure, cancellation, and payload-isolation behavior.
It also verifies the host-aware `tri-r` and `dual-r` routing contracts and the idempotent cross-harness skill installers.
For an end-to-end environment check without executing a real review turn, run:
~~~bash
node bin/relay-cross-host-test.mjs
~~~
The harness checks the five allowlisted executor profiles, `codex`/`claude` CLI
availability (`--version` only), MCP registrations, installed `tri-r`/`dual-r` skills
and native profiles, passive monitor/debug startup, and `npm test` locally and on
`ryzen5wsl`. Use `--json` for CI/log collection, `--local-only` to skip SSH, and
`--skip-suite` for a fast configuration check. A missing CLI or skill is reported as a
failure; the optional Codex native-agent registration is reported as a warning unless
`--strict` is used. The remote check is read-only and does not dispatch work.
For a minimal real smoke test, which does dispatch a fixed read-only prompt through the
actual MCP server and confirms the job in `relay-monitor`, run:
~~~bash
node bin/relay-real-smoke.mjs --json
~~~
By default it exercises `codex-sol-xhigh`, `claude-opus`, and `claude-fable` locally and
on `ryzen5wsl`. It accepts no prompt, command, model, agent definition, or environment
from the payload; the prompt is fixed to `RELAY_SMOKE_OK` with `write:false`. Use
`--agents codex,claude-opus` to reduce the matrix further or `--local-only` to test one
host. This test requires the real CLIs and local Claude authentication; it may consume a
small amount of model quota.
To validate the first Relay Studio business slice — transferring approved context
from workspace A to workspace B — run:
~~~bash
node bin/relay-studio-mvp0-smoke.mjs \
--source-agent codex \
--target-agent claude-opus \
--timeout-ms 180000 \
--json
~~~
The harness creates two temporary workspaces, starts the real MCP server and
allowlisted workers, runs a fixed read-only turn in A, creates a draft handoff,
approves it through the local operator CLI, dispatches only the `handoff_id` to
B, and checks both `relay-monitor` and the bounded changelog. It never copies a
raw buffer or accepts a command/model/agent from the payload. This is a same-host
MVP-0 check; SSHWSL transport, external PTY capture and proactive workflow
transitions remain later slices. Real CLI availability and local authentication
are required.
For a fast deterministic self-test of the relay itself, without Claude/Codex
installation, authentication or model quota, run:
~~~bash
npm run relay:studio:self-test
~~~
This uses injected turns but the real queue, leases, worker processing,
bounded stream spool, Context Store, approval-gated handoff, destination
provenance, changelog and passive monitor. It verifies that the destination
receives bounded approved context instead of the source's private result, that
`write:true` is denied for Claude before the executor runs, and that unknown
profiles are rejected. Use `node bin/relay-studio-self-test.mjs --keep-state
--json` when the temporary state must be inspected after the run.
### Relay Studio context map
For the complete architecture/document map used to discuss Relay Studio with
another LLM, start at
[`docs/relay-studio-chatgpt-context.md`](docs/relay-studio-chatgpt-context.md).
It points to the requirements, RFC, ADR, backlog, visual artifacts, implementation
boundaries and executable tests without duplicating those specifications.
## License
[MIT](LICENSE)
This server cannot be deployed
Maintenance
ActivitySlowing
ResponsivenessNo issues