Agent Relay
Durable, bidirectional messaging between native agent sessions, shipped as a
DeepSeek Harness bundle (dsh-agent-relay-plugin): DSH sessions and Codex threads
exchange messages with push delivery, thread discovery, and delegated work that
returns to the conversation that asked.
A live DSH agent (a real DSH Web session) and a persistent native Codex thread
exchange durable messages. Each side reaches the relay the way that harness is
supposed to be reached: DSH through a registered relay_send tool whose sender
is taken from exec.agent, and Codex through a member-bound stdio MCP companion
whose sender is taken from its bearer credential.
Install
Agent Relay is a DSH bundle. Installing it adds a disabled Host row to a
profile; enabling that row in the profile's user layer starts the relay with every
boot of the profile.
# prebuilt tarball: no build-time scripts, no extra network
dsh plugin --profile web add ./dsh-agent-relay-plugin-0.1.0.tgz
# or from a checkout:
dsh plugin --profile web add /path/to/dsh-agent-relay
# or once published:
dsh plugin --profile web add dsh-agent-relay-plugin
Add the enable block to $DSH_HOME/profiles/web/cordis.patch.yml (a top-level
YAML array). A later layer replaces a row's whole config, so repeat every key:
- id: agent-relay
disabled: false
config:
codexProxy: ~/.codex/app-server-control/app-server-control.sock
codexExecutable: /path/to/codex
allowSessionCreation: true
externalMembers:
- id: codex-tui
tokenFile: $DSH_HOME/agent-relay/codex-tui.token
Defaults: teamId: relay, bridgePort: 33120, journal at
$DSH_HOME/agent-relay/journal.jsonl, status/log beside it, and lazy attachment
to the default Codex daemon socket when no transport is configured. Restart the
profile: the relay starts with it and attaches to Codex whenever the daemon
appears, even if that is after the host booted.
Codex side
Codex reaches the relay through the bundled MCP companion. Add to
~/.codex/config.toml, using the same token as the external member:
[mcp_servers.agent-relay]
command = "/path/to/node"
args = ["$DSH_HOME/profiles/web/node_modules/dsh-agent-relay-plugin/dist/mcp.mjs"]
default_tools_approval_mode = "approve"
[mcp_servers.agent-relay.env]
AGENT_RELAY_URL = "http://127.0.0.1:33120/relay"
AGENT_RELAY_TOKEN = ""
Create the member token file and install the skill into both harnesses:
mkdir -p "$DSH_HOME/agent-relay" && chmod 700 "$DSH_HOME/agent-relay"
printf '%s\n' "$TOKEN" > "$DSH_HOME/agent-relay/codex-tui.token"
chmod 600 "$DSH_HOME/agent-relay/codex-tui.token"
node "$DSH_HOME/profiles/web/node_modules/dsh-agent-relay-plugin/tools/install-skill.mjs" \
--dsh-home "$DSH_HOME" --codex-home "$HOME/.codex" --agents-home "$HOME/.agents"
Check that the layer composed without booting, then restart the profile:
dsh --profile web --dump-config | grep -A4 'id: agent-relay'
Uninstall: dsh plugin --profile web remove dsh-agent-relay-plugin.
Compatibility and verification
| Component | Verified against |
|---|---|
| DeepSeek Harness | 0.2.0-rc.2; engines.dsh is >=0.2.0-rc.2 <0.3.0 |
| Codex CLI | 0.163.0 — stdio app-server, WebSocket app-server, and the shared daemon's unix WebSocket endpoint; the protocol was first probed on 0.162.1 |
| Node | >=22 |
Verified end to end inside a real web profile: push delivery in both
directions, relay_threads discovery, lazy attach when the Codex daemon starts
after the host, delegated work returning to the originating Codex conversation,
and the relay's own ephemeral thread staying out of the user's history. See
Evidence.
How it is wired (and what stays untouched)
The development profile relay-dev is created from the shipped web template
inside a separate DSH home ($PWD/.relay-home/dsh), and the plugin is added
as an extra patch overlay for that boot only:
# once: create the isolated development home and profile
mkdir -p .relay-home/dsh
ln -s ~/.dsh/.credentials.yaml .relay-home/dsh/.credentials.yaml # read-only reuse
DSH_HOME=$PWD/.relay-home/dsh dsh --profile relay-dev --from-default-profile web --help
# every boot
DSH_HOME=$PWD/.relay-home/dsh dsh --profile relay-dev \
--patch "$PWD/dev/relay-dev.patch.yml" --no-open --port 3099
The user's real ~/.dsh profile, sessions and credentials are never modified:
the isolated home borrows the credentials file by symlink and the overlay lives
in this workspace. cordis.patch.yml is the same plugin as a proper bundle
layer, with its row disabled by default so installing the bundle alone
changes nothing until an operator enables it with a config.
Using it with the real environment (web-relay)
A second instance runs inside the user's own ~/.dsh, so it uses their real
credentials, models and session history. It is a new profile created from the
shipped web template plus a copy of their own patch layer; their web profile
is untouched.
# once: create the profile and mirror their bundle list + patch layer
DSH_HOME=~/.dsh dsh --profile web-relay --from-default-profile web --help
cp ~/.dsh/profiles/web/cordis.patch.yml ~/.dsh/profiles/web-relay/cordis.patch.yml
# (package.json: copy theirs, rename "name" to the new profile)
# every boot
DSH_HOME=~/.dsh dsh --profile web-relay \
--patch "$PWD/dev/web-relay.patch.yml" --no-open --port 3100
Notes for that instance:
- It keeps its own journal and Codex home (
relay-personal,codex-personal),
so it never contends with the isolatedrelay-devinstance. - It shares
~/.dsh/storagesand~/.dsh/sessionswith any other running host
on the same home. Two hosts are fine for reading history; keep heavy
workspace/registry edits in one host at a time. dev/web-relay.patch.ymlpinsagent-default-modelto a reachable model
because the profile's own selection (pai,127.0.0.1:30808) was not
reachable when it was booted. Remove that block or switch model in the GUI
once that endpoint is up.- Only sessions live in that host can be borrowed as members; sessions from
the other host appear in its history and can be resumed there.
Connecting a Codex you run yourself (pull path)
The relay drives its own managed Codex. A Codex you run — your TUI, your IDE
session, your daemon — is reached the other way round: it pulls.
# dev/web-relay.patch.yml
bridgePort: 33120 # pin it: the member config must survive restarts
externalMembers:
- id: codex-tui # stable member identity
token: <64 hex random string> # operator-supplied; same value in that Codex's env
Then give that Codex the relay MCP server (its own ~/.codex/config.toml):
[mcp_servers.agent-relay]
command = "/usr/bin/node"
args = ["/path/to/dsh-agent-relay/dist/mcp.mjs"]
default_tools_approval_mode = "approve"
[mcp_servers.agent-relay.env]
AGENT_RELAY_URL = "http://127.0.0.1:33120/relay"
AGENT_RELAY_TOKEN = ""
Restart that Codex (or reload its config) and it gains relay_send,
relay_inbox, relay_members, relay_status. Semantics:
- Messages addressed to the external member stay
queued— nothing is
injected into a process the relay does not own — and they survive relay
restarts. relay_inboxhands them over exactly once; the relay then records
acceptedwithreceipt.deliveredBy: "pull".- Recipients are resolved, not required verbatim:
relay_memberslists every
member with an alias, its title and cwd (for DSH sessions), andtoaccepts an
alias, any unique id prefix, or the full member id. Ambiguity and unknown names
fail with the candidate list instead of guessing. - Members can create new sessions when the operator enables it
(allowSessionCreation, capped bymaxSpawnedMembers):relay_create_session
opens a fresh root DSH session in a chosen directory, or an additional
persistent Codex thread, and can hand it its first task through the ordinary
relay path. DSH sessions are created through the host's session controller, so
preset and model resolve before the first turn;dshAgentPresetoverrides the
preset. - The external Codex can
relay_sendto any member; the DSH side is woken
normally. - Push into a thread another Codex process is currently holding is not
supported; that needs the still-unimplemented daemon/control-socket attachment.
Verified end to end: evidence/unmanaged-codex-pull.txt, reproducible with
node tools/unmanaged-codex-pull.mjs --url --token.
Copy-paste prompts for the Codex side (TUI and codex exec) are in
examples/from-codex.md.
Skill: teach the agents the protocol
skills/agent-relay/ is one skill bundle both harnesses understand — a
SKILL.md with name/description frontmatter plus a references/operations.md
that is loaded only when needed. It tells an agent to find members with
relay_members instead of inventing ids, how to address by alias/title/prefix,
when to drain with relay_inbox, how requestIds make retries safe, and what each
delivery state means.
node tools/install-skill.mjs --dry-run # preview
node tools/install-skill.mjs \
--codex-home ~/.codex --dsh-home ~/.dsh --agents-home ~/.agents
It symlinks the repository bundle into each root, so edits here are live:
| harness | root | discovery |
|---|---|---|
| Codex | $CODEX_HOME/skills/agent-relay/ |
on start |
| DSH | /skills/agent-relay/ (rank 400) |
watched; appears without restart |
| other agents | ~/.agents/skills/agent-relay/ (DSH rank 500) |
watched |
Verified on this machine: the skill is listed in a live DSH session's catalog and
in Codex's rendered prompt input (codex debug prompt-input).
Run
Requires Node >= 22.
npm install
node tools/link-host-packages.mjs # point @deepseek-ai/* at the installed DSH packages
npm run build # tsc -> dist/index.js, plus the runtime .mjs modules
npm test # 7 core/bridge/MCP tests
npm run check # typecheck + syntax check, no emit required
# two-round acceptance run against a booted dev host (see above)
node tools/round-trip.mjs
tools/link-host-packages.mjs exists because the bundle's own module lives in
this workspace: linking the @deepseek-ai/* dependencies to the installation
guarantees the plugin is compiled and executed against exactly the host modules
that are running, instead of a second copy from the registry.
Attaching your own Codex (true push)
The relay can own the Codex App Server over WebSocket instead of a private stdio
pipe. A Codex TUI then attaches to the same conversation, so turns the relay
pushes from DSH appear live in the session you are looking at, and turns you type
are visible to the relay.
# dev/web-relay.patch.yml / operator config
codexListen: ws://127.0.0.1:18799
The plugin spawns codex app-server --listen --ws-auth capability-token
--ws-token-file .codex-ws-token (0600, loopback only), connects itself,
and publishes the attach command in its log and status file:
export AGENT_RELAY_CODEX_TOKEN=$(cat .relay-home/relay-personal/journal.jsonl.codex-ws-token)
codex --remote ws://127.0.0.1:18799 --remote-auth-token-env AGENT_RELAY_CODEX_TOKEN
Verified with a recording client in place of the TUI: a DSH message to codex
appeared as turn/started → userMessage → mcpToolCall → turn/completed in
the attached session, and Codex's reply woke the DSH session — no polling on
either side (evidence/codex-attach-demo.txt, evidence/codex-ws-multiclient-probe.txt).
Threads you open in that attached TUI get the relay's MCP tools too: in
WebSocket mode the plugin gives the whole app-server the MCP server at process
level, and adopts whichever thread you are in as the push member
codex-attached (resumed conversations included — adoption triggers on any
notification, not just thread/started). So relay_members from a DSH session
shows codex-attached, and messages to it run as turns in the session you are
looking at; its replies come back addressed to the sender. One attached
conversation is represented by that member at a time; opening another rebinds it.
Evidence: evidence/codex-attached-thread-fix.txt.
A second client can also resume the thread and submit turns; a turn submitted
while another is active merges into it, so the relay still drains only at idle
boundaries.
Attaching the relay to a server you own (codexAttach)
The opposite split is also supported: you start the app-server (your CODEX_HOME,
your config, your skills) and the relay attaches as a client instead of spawning
one.
codexAttach: ws://127.0.0.1:18899
codexAttachTokenFile: /path/to/that-servers-capability-token
# you run this, in your terminal / supervisor:
CODEX_HOME=~/.codex codex app-server --listen ws://127.0.0.1:18899 \
--ws-auth capability-token --ws-token-file ~/.codex/relay-ws-token
# your TUI joins the same server (both clients see the same threads):
codex --remote ws://127.0.0.1:18899 --remote-auth-token-env AGENT_RELAY_CODEX_TOKEN
The relay creates its own thread on that server and supplies the relay MCP server
through per-thread config (verified end to end: two-round run passed, and
stopping the relay left the external server healthy — evidence/codex-attach-external-server.txt).
The relay's own Codex thread on that server is ephemeral: it runs turns and
carries the relay MCP server, but it is not written into the user's history and
leaves no rollout (a non-ephemeral thread there would show up as an unattachable
"task" in their TUI). It is replaced on every relay start.
The relay never stops a server it does not own. Two consequences:
- Threads that other clients create on that server get the relay tools only if
the server itself was launched with process-level
-c mcp_servers.agent-relay.…(relay-owned mode adds that automatically). - Restarting the external server drops the relay's connection; it does not
reconnect automatically, and deliveries around that moment areuncertain.
Attaching to the daemon your TUI already uses (codexProxy)
A plain codex TUI joins a shared background app-server when one is running
(codex --no-daemon opts out), and its control socket is
/app-server-control/app-server-control.sock. The relay can join that
same daemon instead of creating its own server:
codexProxy: ~/.codex/app-server-control/app-server-control.sock
The plugin runs codex app-server proxy --sock and speaks stdio JSON-RPC
through it. Consequences: the relay's thread lives in your real ~/.codex history,
the conversation your TUI has open is discovered (via thread/list polling plus
notifications) and adopted as the push member — by default the first configured
external member, i.e. the identity your Codex config authenticates as — and the
daemon is never stopped on unload. Startup command (run from the terminal that
shares the daemon's /tmp):
cd /path/to/dsh-agent-relay
DSH_HOME=~/.dsh dsh --profile web-relay \
--patch "$PWD/dev/web-relay-daemon.patch.yml" --no-open --port 3102
Status: working. The control socket is a WebSocket server at path /, and the
plugin talks to it directly (UnixWebSocket in src/codex.mjs) — the ws
library's socketPath option is ignored and codex app-server proxy did not
answer, which is what blocked earlier attempts. Verified end to end against a real
unix app-server: attach, thread/start, a completed turn, 23 notifications, and
the server outliving the client (evidence/codex-shared-daemon-attach.txt).
Start the host from the same session as the daemon:
cd /path/to/dsh-agent-relay
DSH_HOME=~/.dsh dsh --profile web-relay \
--patch "$PWD/dev/web-relay-daemon.patch.yml" --no-open --port 3102
Caveats: the proxy host must run in the same session as the daemon (its socket
lives in that session's /tmp); a daemon started before the current
binary/config was installed keeps that older state until restarted; and the relay
does not auto-reconnect if the daemon restarts.
Development install (linked checkout)
For iterating on the plugin itself, install the checkout as a link so rebuilt
dist/ files are picked up on the next host start:
npm install && npm run build
dsh plugin --profile web add /path/to/dsh-agent-relay
The enable block and Codex-side setup are the same as in Install.
Rebuild and restart the profile to pick up changes; npm test runs the full
suite and dsh --profile web --dump-config shows the composed layers.
Thread discovery and addressing specific conversations
relay_threads (DSH tool and MCP tool) lists both sides: open Codex conversations
with thread ids and the member that addresses each (codex-<8hex>), the attached
conversation (codex-tui), the relay-owned thread (codex), and DSH sessions with
session id, title, cwd and live. Every persisted thread the app-server reports as
loaded becomes a push member, so a message can be written into a specific existing
Codex conversation instead of only the attached one. Ephemeral threads and threads
without a rollout are never exposed. See evidence/thread-discovery.txt.
Inspecting the relay
Three surfaces, all usable without a Codex or DSH session:
# read-only journal (no credentials): members, messages, state counts
node tools/relay-cli.mjs states
node tools/relay-cli.mjs members --journal .relay-home/relay-personal/journal.jsonl
node tools/relay-cli.mjs messages --state queued --limit 20
# the bridge is loopback HTTP: every method is POST /relay with a member token
curl -s -X POST http://127.0.0.1:33120/relay -H "Authorization: Bearer $TOKEN" \
-H 'content-type: application/json' -d '{"method":"members","params":{}}'
# methods: members | status | send | inbox | drain | discard | create
# (no token -> 401; GET -> 404)
# settle mail that nobody will drain (scoped: a member can only discard its own)
node tools/relay-cli.mjs discard --all --reason "stale" --token "$TOKEN"
node tools/relay-cli.mjs discard --token "$TOKEN"
# the MCP companion is stdio JSON-RPC, not HTTP: pipe frames into it
node tools/relay-cli.mjs mcp --token "$TOKEN" --call relay_members
printf '{"jsonrpc":"2.0","id":1,"method":"tools/list","params":{}}\n' | \
AGENT_RELAY_URL=http://127.0.0.1:33120/relay AGENT_RELAY_TOKEN="$TOKEN" node dist/mcp.mjs
node tools/relay-cli.mjs curl prints all of the above with the current paths
filled in. From the Codex side, codex mcp get agent-relay shows how that Codex
sees the server.
Contents
| Path | Purpose |
|---|---|
src/index.ts |
Host plugin: borrows live DSH agents, owns the Codex thread, registers tools, compiles to dist/index.js |
src/core.mjs |
Exclusive journal owner: durable messages, request dedup, generation pinning, serialized delivery |
src/bridge.mjs |
Loopback HTTP API; each bearer credential binds one member generation |
src/codex.mjs |
Codex App Server client verified against codex-cli 0.162.1 |
src/mcp.mjs |
Member-bound stdio MCP companion (relay_send, relay_members, relay_status) |
dev/relay-dev.patch.yml |
Development overlay that wires the compiled plugin into relay-dev |
cordis.patch.yml |
Bundle patch layer (row disabled until configured) |
tools/round-trip.mjs |
Two-round acceptance harness (creates its own DSH Web session) |
tools/dsh-web-client.mjs |
Token exchange + /api RPC client used to drive a real Web session |
tools/codex-mcp-approval-probe.mjs |
Probe that established which MCP approval configuration Codex accepts |
skills/agent-relay/ |
One SKILL.md bundle both harnesses read: addressing, sending, draining, states |
tools/install-skill.mjs |
Installs that skill into Codex, DSH and ~/.agents skill roots |
tools/relay-cli.mjs |
Inspect members/messages from the journal, the HTTP bridge, or the stdio MCP server |
tools/unmanaged-codex-pull.mjs |
Drives an unmanaged Codex through the pull path (relay_inbox -> relay_send) |
tools/codex-account-check.mjs |
Isolates home/auth/network from app-server daemon state when a Codex TUI bootstrap fails |
dev/web-relay.patch.yml |
Real-home overlay for the web-relay profile (own journal + Codex home) |
research/dsh-plugin-api.md |
Every DSH seam used, quoted from the installed .d.ts / lib |
research/codex-api.md, research/codex-probe-output.txt |
Codex App Server protocol reference and raw probe transcript |
Verified host behavior the design depends on
DeepSeek Harness 0.2.0-rc.2 (details and quotes in research/dsh-plugin-api.md):
- A plugin is an ES module with
name,inject = ['tools', 'agents'],
Config(Schemastery) andapply(ctx, config); noexport default. ctx.tools.register(defineTool({...}))accepts the definition;exec.agent
is the callingAgent, andagent.session.idis the live session id.agent.followup(message)queues an ordinary next turn and wakes the driver;
it never steers a running step.agent.status(idle/running) plus the
agent/statusevent are the idle-boundary signal.ctx.agents.roots()is how an already-open Web session is borrowed rather
than recreated.- A user-role message requires an explicit
source.kind. The relay declares its
ownagent-relaykind instead ofuser, becausekind: 'user'is the
host-attested human marker that grants goal-completion authority.
Codex CLI 0.162.1 (details in research/codex-api.md):
thread/start/thread/resumeaccept an unconstrainedconfigobject that
carries per-thread MCP servers:config.mcp_servers..{command,args,env}.- Threads persist across app-server restarts;
thread/resumerestores the same
conversation (verified with a fresh process answering about an earlier turn). turn/startis not a mutual-exclusion primitive. A secondturn/start
on an active thread is accepted and its input is merged into the active turn
instead of returning an error. The relay therefore treatsacceptedas
"the harness admitted this input", drains only at idle boundaries, and never
claims turn exclusivity.- MCP tool calls need an explicit approval mode. With
approvalPolicy: 'never', an unconfigured MCP server fails the model's tool
call withMCP tool call requires approval, but approval policy is never.
default_tools_approval_modeacceptsauto | prompt | writes | approve; only
approvemakes the call succeed (verified at both the-cand per-thread
configlevel;autoandwritesstill fail). The relay setsapprovefor
its own MCP server only and documents it as an operator decision. - Server-to-client approval/user-input/elicitation requests are answered with a
refusal and recorded; the relay never grants one. - Codex injects a minimal environment into stdio MCP servers —
HOME LANG LOGNAME PATH SHELL TERM USERplus that server's configuredenv(verified with a dump
server) — so a member derived from an MCP credential identifies the member,
never the Codex thread that called it. Thread-level provenance exists only where
the relay owns the app-server and can readthread/start/turn/completed.
Delivery guarantees
A send acknowledges journal persistence, not model execution. An accepted
delivery means the harness accepted the input. It does not mean consumed,
understood or done. The core records uncertain BEFORE submission; ambiguous
submissions are never retried automatically. Inspect native history and
reconcile it before redelivery. Already accepted messages do not replay.
Task IDs and kinds are routing metadata; there is no task scheduler or
completion engine. kind: 'complete' is a message from one agent, not a
verified task outcome.
Idle-boundary delivery: messages queued for a member are delivered when that
member reaches an idle boundary (Codex turn/completed, DSH agent/status →
idle), or immediately when a send is made. A busy DSH session receives the
callback as a queued follow-up turn, never as mid-step steering.
Current limits
- Approvals. Codex sandbox/file-change/exec approval requests are refused,
not routed to a DSH user surface. Until that exists, a Codex task that needs a
sandbox approval will not complete; the refusal is recorded, and no permission
is silently granted.research/codex-api.mddocuments the exact decision
payloads a real approval surface must answer. - One process owns the journal. A second owner is refused while the recorded
heartbeat is fresh; a lock whose heartbeat stopped for
LOCK_STALE_MS(10s) is reclaimed automatically. A torn journal still fails
closed and needs operator repair. - Delivery is globally serialized in one process, and
drain()walks queued
messages in order. No daemon proxy attachment, remote transport, artifact
transfer or dashboard. - A recovered
uncertainmessage is never retried automatically. Reconcile
it against native history first. - Queued mail for a member nobody runs never expires. Settle it explicitly
withdiscard(the journal records who/why); it is never dropped silently. - Existing Codex daemon attachment is not supported: the plugin spawns its
owncodex app-server(managed mode) under a per-memberCODEX_HOME. - The plugin borrows only root DSH agents; subagents and owned children are
not members.
Evidence
research/dsh-plugin-api.md— every DSH API used, quoted from the installed
.d.ts/ builtlib/*.js.research/codex-api.mdandresearch/codex-probe-output.txt— Codex App
Server reference and a real protocol transcript (raw frames), including the
busy-thread, persistence, MCP-config and approval findings.tools/codex-mcp-approval-probe.mjs— reproduces the approval-mode matrix;
captured inevidence/codex-mcp-approval-matrix.txt(approveworks,auto
andwritesfail underapprovalPolicy: never).tools/round-trip.mjs— the acceptance run: two rounds, same DSH session,
same Codex thread, DSH session wakes after each MCP callback; captured in
evidence/round-trip-acceptance.json(isolated home) and
evidence/round-trip-real-home.json(the user's real~/.dsh).evidence/unmanaged-codex-pull.txt— a Codex the relay does not own pulls
queued work withrelay_inboxand answers back, waking the DSH session.evidence/busy-session-gate.txt— a callback queued against a mid-turn DSH
session (queuedWhileRunning: true) and two DSH sessions exchanging messages
by addressed member id.evidence/relay-plugin-lifecycle.logandevidence/relay-status-final.json—
the dev host's plugin lifecycle lines and final status snapshot (no
credentials). The live copies stay under.relay-home/run/.