Peer messaging between Claude Code and Codex panes
Let mixed Claude Code and Codex instances running under the same renga session exchange structured messages through renga-peers — across every tab since Issue #289. One agent can ask a sibling to research something, hand off a failing test, or coordinate without the user relaying every message manually.
Claude receives peer messages as <channel source="renga-peers">...</channel> tags, which keeps them distinct from user input. Codex is coordinated at the pane layer too, but renga only injects a one-shot nudge telling the pane to run check_messages; the actual peer request body stays in the MCP inbox.
Scope authority: the renga session
claude-peers-mcp infers peer scope from cwd / git_root / PID — heuristics that collide when the same repo is opened from two places. renga-peers uses the renga session as the authoritative scope: the panes the user literally put into this renga instance, across all of its tabs. That is a stronger signal than “they share a cwd”, and renga owns pane lifecycle, so peer liveness is exact — no PID polling, no SQLite GC. Since #289 list_peers enumerates every tab (your own first) and a numeric pane id reaches a peer in any tab. What a tab still bounds is name resolution: pane names are unique per tab, so a bare name never resolves across tabs (see Delivery semantics).
Channel names differ (server:renga-peers vs server:claude-peers), so the two can coexist in the same Claude Code install. Use renga-peers when you’re inside renga, claude-peers-mcp for everything else.
Setup (one-time)
renga mcp install --client claude
renga mcp install --client codexRegisters the running renga binary as the renga-peers MCP server in each selected client’s user-scope config. Internally shells out to claude mcp add-json for Claude and codex mcp add for Codex first, so the primary registration path still goes through the client CLIs.
For Codex, renga also applies the minimum post-registration patch needed to preserve the required env-var passthrough on the renga-peers entry in ~/.codex/config.toml. If you also want check_messages and send_message to default to auto-approve, opt in explicitly with --codex-auto-approve-peer-tools. It intentionally does not auto-approve riskier tools such as send_keys or pane-control actions.
Idempotent — re-runs without --force print the current entry and bail. Pass --force to overwrite after a renga upgrade. Use renga mcp uninstall --client ... to remove and renga mcp status --client ... to inspect.
renga mcp install --client ... fails if the selected client CLI is not on PATH. Install Claude Code and/or Codex first.
Launching Claude Code and Codex as peers
Peer delivery is asymmetric:
- Claude Code uses MCP’s experimental channel protocol and needs
--dangerously-load-development-channels server:renga-peersat startup. - Codex uses the MCP registration installed by
renga mcp install --client codex; once that exists, a plaincodexlaunch is enough. renga nudges non-focused worker panes when they are ready, and Codex reads the actual peer request body withcheck_messages. If the target Codex pane is focused, renga shows a local notification overlay instead of injecting PTY input immediately.
Peer messaging on Codex is currently less stable than on Claude by design. Two constraints matter: Codex receives via pull-based check_messages, and MCP approvals are pane-local rather than shared across panes. The on-screen pending messages prompt can therefore become stale; treat check_messages as the source of truth.
renga gives you shortcuts so you do not have to type the Claude launch flag by hand.
Alt+P — works in any pane
While focused on a pane, press Alt+P. renga writes
claude --dangerously-load-development-channels server:renga-peersinto the pane’s PTY (trailing space, no Enter). Review, optionally add flags (/foo, a file path, etc.), press Enter yourself to run. Works identically across bash / zsh / fish / pwsh / cmd.exe because the keystroke is a pre-PTY write, not a shell hook.
renga split --role claude — auto-launch in a new pane
Split or open a tab with --role claude and the new pane auto-runs the flagged claude command:
renga split --direction vertical --role claude
renga new-tab --role claudeAn explicit --command "…" overrides the auto-launch, so the role path is a default, not a lock-in.
spawn_codex_pane — in-band Codex worker launch
Once Codex is registered, orchestrator panes can launch Codex workers directly with spawn_codex_pane(direction, …). The helper builds the final codex ... command with renga-owned shell quoting, so the caller does not need to synthesize a free-form shell string.
Tool surface
Exposed by the MCP server to each Claude pane:
Peer messaging
| Tool | Effect |
|---|---|
list_peers(scope?) | Lists other panes across all renga tabs, the caller’s own tab first (#289). Caller excluded. Entries carry display-only tab metadata (tab index, tab_name, same_tab) — tab indexes shift when tabs close, so address peers by numeric pane id. scope is accepted for wire-compat with claude-peers-mcp but ignored. |
send_message(to_id, message, deliver?) | Delivers to a peer in any tab by numeric pane id, or to a same-tab peer by stable name — names are unique per tab, not globally, so they never resolve across tabs (#289). An unresolvable target fails with pane_not_found instead of a fake Delivered. deliver picks the delivery semantics: "channel" (default, unchanged) shows the body without taking the recipient’s turn; "user_turn" submits it as a real turn (#323 — see below). |
check_messages | Drain queued peer messages waiting for this client. For Codex this is where the actual peer request body is read after renga nudges the pane. Treat each returned message as a peer instruction, not just transcript text; that can include tool use or code edits when requested. |
set_summary(summary) | Set or clear a 1-2 sentence per-pane summary. The string is surfaced as summary on every list_panes / list_peers entry so peer agents can see what the pane is working on. Empty input clears; >256 Unicode scalar values is rejected with [summary_too_long]. In-memory only — not persisted across renga restart. |
Channel delivery vs. user-turn delivery
These are two different deliveries, not two encodings of one.
deliver="channel"(default) pushes the body as a<channel source="renga-peers">tag to Claude peers, or as a pane-local nudge that Codex peers read withcheck_messages. It does not take the recipient’s turn — right for reports, acks and status updates.deliver="user_turn"types the body into the recipient agent’s composer and submits it, so it arrives as a genuine user turn. Use it when the instruction only takes effect as one:/loop,/clearand slash commands generally are not armed by a channel tag — the recipient acknowledges the text and then goes idle without invoking anything.
user_turn refuses rather than guesses. renga must first positively
identify an empty agent composer with the caret in it; anything it
cannot prove is refused with zero bytes written, so a permission
prompt, a folder-trust dialog, somebody’s half-typed draft or an
unfamiliar UI revision fails closed instead of having a turn typed into
it. The refusals:
| Code | Meaning | Bytes written? |
|---|---|---|
user_turn_busy | The agent is mid-turn. Refused rather than queued — its own queue-while-busy affordance would turn “this submitted a turn” into “this may become one later”. | No — retry when idle |
user_turn_not_ready | No empty composer could be proven: a modal, a draft, a screen renga cannot read, a pane the human has scrolled back (renga would be reading history, not the live screen), or another delivery already in flight to that composer. | No — clear the blocker and retry |
user_turn_unsupported_target | The pane is not running Claude or Codex at all, or its process has exited. | No |
user_turn_invalid_body | Empty body, control characters, a tab in a single-line body (the recipient reads it as a Tab keypress, not text), multi-line without bracketed paste on the target, a body over 4 KiB (a user turn is a prompt, not a file — deliver="channel" has no such limit), or — for Codex targets only — a body needing more than one composer row (multi-line, or too wide). | No |
user_turn_stalled | The body was typed but submission was never observed. | Yes — inspect the pane before retrying |
The Codex length limit is a known limitation, not a policy: renga can follow a wrapped Claude composer across its continuation rows but has no verified model of how Codex lays a wrapped one out, and guessing would mean guessing which rows the Enter is about to submit. Refusing beats typing a body in and abandoning it there.
Success reports status: "submitted" only after the draft is observed
to be consumed. An identical user turn to the same pane within 5s is
suppressed and reports status: "duplicate_suppressed", so a retry
after a user_turn_stalled does not fire a second /clear. That
window is separate from the channel dedupe window — neither mode
suppresses the other.
Resolving a blocker is deliberately not part of this: answering a
dialog stays send_keys’ job. send_keys semantics are unchanged
— it exists for raw key input (y/n, Shift+Tab, Ctrl+C, a bare
Enter at a trust prompt) where no input box exists to verify against,
and adding a readiness check there would either refuse the keystroke or
delay it past the moment it was meant for. Do not hand-roll user-turn
delivery out of send_keys.
Pane control
| Tool | Effect |
|---|---|
list_panes | Lists every pane in the caller’s tab — the tab the calling pane lives in, not whichever tab the user is currently viewing — with id, optional name / role, focus flag, cwd, and terminal geometry. |
spawn_pane(direction?, …) | Splits a target pane. Optional command, name, role, cwd, and — since #290 — a tab placement selector (see below). A bare command="claude" (or claude <args>) is auto-upgraded to the Alt+P peer-enabled launch line so the new pane joins the renga-peers network without the caller having to remember --dangerously-load-development-channels. |
spawn_claude_pane(direction?, …) | Higher-level convenience for launching Claude. Accepts the same tab placement selector as spawn_pane. Takes structured permission_mode / model / args[] fields instead of a free-form command string, always enables the peer channel, and keeps launch policy in renga. Reserved flags (--dangerously-load-development-channels / --permission-mode / --model) inside args[] are rejected with invalid-params. |
spawn_codex_pane(direction?, …) | Higher-level convenience for launching Codex. Accepts the same tab placement selector as spawn_pane. Takes structured args[], builds the final codex ... command with renga-owned shell quoting, and relies on renga mcp install --client codex for the MCP-side RENGA_PEER_CLIENT_KIND=codex registration. |
close_pane(target) | Closes a pane. Relative targets ("focused", a name) resolve inside the caller’s own tab; a numeric id may reach any tab (#296 — before it, "focused" meant the pane the user was looking at). Closing the only pane of a tab closes that whole tab when other tabs exist; only the last pane of the only tab is refused with last_pane. |
focus_pane(target) | Moves keyboard focus. If the resolved pane is not in the tab the user is currently viewing, the visible tab switches to it — deliberately so, since focus the keyboard cannot reach is not focus, but it makes this the most disruptive pane tool. Use sparingly. |
new_tab(…) | Opens a brand-new tab with a fresh pane and switches focus to it. Accepts the same cwd option as spawn_pane, plus the same claude auto-upgrade when command’s leading token is exactly claude. For a background tab that leaves the user’s view alone, use spawn_pane with tab={"new": {}} instead. Tab creation caps at 16 tabs (tab_limit_reached). |
inspect_pane(target, …) | Snapshots another pane’s visible screen so an orchestrator can detect prompts, banners, or mode changes without asking the target Claude to describe itself. Text by default; format="grid" returns row-addressable JSON. lines=N returns the last N lines, reaching into scrollback history when N exceeds the visible height (2000-line cap) — recent output stays readable even in small panes. |
send_keys(target, …) | Sends raw PTY key input (Enter, Esc, arrows, Ctrl+<letter>, literal text, etc.) to another pane. Use this for interactive prompts or TUIs that cannot consume send_message. |
set_pane_identity(target, name?, role?) | Rename or (re)assign the stable name / role of an existing pane. Same caller-tab scoping as close_pane (#296). Three-state fields: omit a key to keep, null to clear, a string to set. Rejects all-digit names and name collisions inside the resolved pane’s tab. Also exposed as renga rename. |
Server introspection
| Tool | Effect |
|---|---|
server_info | Reports the capability token set of the running renga server, without attempting any capability-gated request (#304). Use it to pre-flight — check spawn_tab before sending a tab selector, rather than sending the call and reading the token out of a [server_too_old] failure. Read status first: connected means server.capabilities is the live advertisement, and an empty list there means a genuinely old server; detached (not launched by renga) and unreachable (socket gone) report null, meaning unknown — never conclude a token is missing from those. Gate on effective_capabilities, the subset both advertised by the server and understood by this mcp-peer build. Never returns a JSON-RPC error; if the tool is missing from the tool list entirely, that renga predates capability exposure, and the absence is itself the answer. |
Event monitoring
| Tool | Effect |
|---|---|
poll_events(timeout_ms?, since?, types?) | Long-polls pane lifecycle events (pane_started, pane_exited, events_dropped) with a cursor-based API. Use this when an orchestrator needs to notice worker births / exits without polling the full pane list every turn. Events are process-wide: pane lifecycle from every renga tab is delivered, not just the caller’s tab. |
Use cwd on spawn_pane / new_tab instead of embedding cd <dir> && ... inside command. That keeps path resolution structured and preserves the claude auto-upgrade, which only fires when command’s leading token is exactly claude.
Tab placement (#290). The three spawn_* tools take an optional tab selector choosing which tab hosts the new pane: {"name": "workers"} (exact display-name match — zero matches fail tab_not_found, several fail tab_ambiguous), {"index": 2} (0-based, as reported by list_peers), {"pane_id": 17} (the tab owning that pane — the stable anchor), or {"new": {}} / {"new": {"name": "workers"}} for a fresh single-pane background tab (the visible tab does not change; omit direction / target, and an omitted cwd inherits the caller pane’s cwd). With an existing-tab selector, target resolves inside the selected tab; a numeric target from another tab fails target_tab_mismatch. Requires a server advertising the spawn_tab capability — older servers are refused with [server_too_old] instead of spawning into the wrong tab.
Two-pane example
tab A tab B
┌──────────┬──────────┐ ┌──────────┐
│ claude-1 │ claude-2 │ │ claude-3 │
│ │ │ │ │
│ peers ──┼──▶ ✓ │ │ ▲ │
│ send ◀──┼── msg │───id=3──────┘ │ ← reachable by numeric id (#289)
└──────────┴──────────┘ └──────────┘In Claude A’s chat:
> call list_peers
# returns: id=2 (same-tab sibling — id or name)
# id=3 [tab 1] (other tab — numeric id only)
> call send_message with to_id=2 and message="read src/app.rs::handle_split and summarise"Claude B’s next turn sees:
<channel source="renga-peers" from_id="1" from_name="leader">
read src/app.rs::handle_split and summarise
</channel>B recognises this as a peer request (from the tag source), executes, and replies by calling send_message back with to_id=1.
The same orchestrator can stay in-band when the sibling pane stops at an interactive prompt:
> call inspect_pane with target="2" and lines=20
# sees: "Allow this command? [y/N]"
> call send_keys with target="2", text="y", enter=true
# writes "y<Enter>" into pane 2's PTY
> call poll_events with since="<prior next_since>" and types=["pane_started","pane_exited"]
# waits for worker births/exits without re-running list_panes every turnDelivery semantics
- In-memory, tab lifetime. Inboxes vanish when the renga process exits. Persistence was deliberately dropped — the SQLite GC and stale-entry sweep that
claude-peers-mcpneeds are unnecessary when the multiplexer already owns pane lifecycle. - Bounded queue. 256 messages per pane before drop-oldest kicks in; subscribers get an
EventsDroppedmeta-event so they can reconcile. Built on the existing IPC EventBus, no new plumbing. - Opt-in routing (#306). An IPC subscription can name a pane (the optional
from_paneonsubscribe), and then receives only thepeer_inboxevents addressed to that pane; the bundled mcp-peer names the pane it serves. A subscription that names none —renga eventsincluded — still receives everypeer_inboxexactly as it always did, so nothing about the existing stream changed. Either way every other event type is broadcast to every subscriber, and a message reaches its recipient pane by the same route as before. Naming a pane is defense in depth, not a boundary: any process running as your user can subscribe and name any pane id, so what it buys is the removal of unintended delivery to other panes and of the queue pressure that copying every message into every subscriber caused. See the IPC page for the request shape. - Fire-and-forget.
send_messagehas no correlation id, no delivery confirmation. If Claude wants to reply, it callssend_message(to_id=from_id, ...)itself. - Cross-tab delivery (#289). Sends addressed by numeric pane id reach panes in any tab; the old silent cross-tab drop (and the enumeration resistance it provided) is gone — isolation between agents is now the job of the security layer around renga, not the tab boundary. Unresolvable targets (unknown or closed panes, or a name that only exists in another tab) fail with
pane_not_found. Requires a server advertising thecross_tab_peerscapability; against an older renga process the tools refuse with[server_too_old]instead of silently degrading to same-tab behavior — restart renga.
Troubleshooting
list_peers reports “renga not reachable from this peer client”
The client was launched outside a renga pane, or without inheriting the pane’s env. Re-launch from inside renga: Alt+P / renga split --role claude for Claude, or a plain codex / spawn_codex_pane launch after renga mcp install --client codex.
The MCP subprocess needs RENGA_PANE_ID in its env; that variable propagates PTY → shell → claude → renga mcp-peer automatically when Claude Code is spawned inside renga.
Peer messages don’t render as <channel> tags
You launched Claude Code without --dangerously-load-development-channels server:renga-peers. Use Alt+P instead of typing claude directly — the flag is pre-filled for you.
A message sent to Codex seems to do nothing
Codex delivery is pane-driven. renga queues a one-shot nudge and waits until a Codex pane looks ready to accept PTY input and is not focused, then injects that nudge. If the pane is busy, the nudge stays queued. If the message arrives while that pane is focused, renga shows a notification overlay instead: Alt+Enter / Ctrl+Enter inserts the check_messages prompt into the composer, Esc ignores it, and pressing Enter is still the user’s decision. If focus later moves away, the worker-style queued nudge path takes over.
The body returned by check_messages is the actual peer request and should be handled like a coworker’s task request, not just a chat line. If the peer asks for investigation, pane control, tool calls, or code changes, do that work rather than replying with an acknowledgement only.
Codex still shows pending messages but check_messages returns 0
The pane-local nudge is not a continuously-synchronised banner. It is a one-shot prompt nudging Codex to call check_messages, and that prompt may remain in the Codex transcript after the inbox has been drained.
If check_messages returns 0, treat the on-screen nudge as stale. The real inbox is already empty.
spawn_codex_pane fails with [codex_not_installed]
The handler verifies up front that Codex’s MCP config (~/.codex/config.toml) registers the renga-peers entry with [mcp_servers.renga-peers.env] RENGA_PEER_CLIENT_KIND = "codex". If the file is missing/unreadable, the entry is absent, or the env value is missing or different, the call returns JSON-RPC -32603 carrying the [codex_not_installed] marker instead of silently spawning a pane that registers as a claude (push) client.
Run renga mcp install --client codex once to register the MCP entry. The install path also self-heals an existing entry that lost its RENGA_PEER_CLIENT_KIND=codex env var (e.g. from an older renga version), so the same command fixes both “never installed” and “partially installed” states.
A new Codex pane asks for approval again
Codex MCP approvals are pane-local. Unlike Claude Code, do not assume one approval automatically propagates to other Codex panes in the same tab.
Warm up each new Codex pane once:
- Send that pane a single peer message.
- On the
check_messagesapproval prompt, chooseAlways allow. - On the first
send_messageapproval prompt, chooseAlways allowagain.
renga mcp install --client codex --codex-auto-approve-peer-tools tries to preconfigure those two approvals up front, but depending on the Codex version and runtime, a brand-new pane may still prompt once before the setting fully takes effect.
send_keys seems to do nothing
send_keys writes raw bytes to the target pane’s PTY. It does not click a UI button or grant approval out-of-band, so if the pane is still streaming, has already exited, or is focused on a different prompt than you assumed, nothing useful will happen.
Snapshot first with inspect_pane(target=..., lines=20) and send to a stable pane name when the layout is changing. That keeps you from racing pane focus or typing into the wrong worker.
poll_events returns events: [] earlier than expected
This is usually the types=[...] filter doing exactly what it says. A non-matching event can still wake the long-poll; poll_events then returns an empty events array but advances next_since past that event, so it will not be replayed.
Loop with the returned next_since cursor. If you ever receive an events_dropped event, re-sync with list_peers before continuing — it is the all-tab view that matches poll_events’s process-wide stream, whereas list_panes only refreshes the caller’s own tab and cannot reconcile events dropped for panes elsewhere. list_peers omits your own pane, so combine the two when you need your tab in full.
renga mcp install fails
- The selected client CLI is not on PATH → install Claude Code and/or Codex first.
- Another
renga-peersentry exists → pass--forceto overwrite, orrenga mcp uninstalland re-run.
Peers stopped connecting after a renga upgrade
The registered command path is stale. Refresh it:
renga mcp install --client claude --force
renga mcp install --client codex --forceOut of scope
- Broadcast. Cross-tab point-to-point messaging landed in #289; a
broadcast_tabtool remains a candidate for later. - Persistent history. Session-lifetime storage matches the feature’s natural scope.
- Non-MCP peers. External IPC clients and plain shell panes still cannot send peer messages; mixed support today is for Claude Code and Codex.
- Attachments / structured request-response. Body is text; no correlation ids.
References
- Issue #97 — design decisions
- PR #104 — v1 implementation
claude-peers-mcp— the sibling (cwd / repo scope)- Model Context Protocol