SOP: the RDCO agent message bus (agent-to-agent + agent-to-Ray)
How background agents and the main channels-agent ("Ray") pass messages, in both
directions, using a local append-only file bus + a Monitor. Built + validated
2026-05-31 after the Discord plugin proved unusable as an A2A transport (it drops
all bot-authored messages — server.ts:803 if (msg.author.bot) return).
Why a file bus (not Discord)
- Discord delivers inbound only from allowlisted humans; every bot-authored message is filtered before it reaches a session. So one Ray session cannot push to another via Discord. (Tested + confirmed.)
- The file bus is in-process, structured, identity-free, and pushes via the harness's own Monitor primitive. Discord stays the human ↔ Ray channel.
The pieces
- Bus file:
~/.claude/state/agent-bus.jsonl— append-only, one JSON row per message:{ts, from, type, msg}.type∈ status | done | needs-input | alert | result | reply | instruction. - Post helper:
~/.claude/scripts/agent-bus-post.sh <from> <type> <msg...>— appends one row (atomic append). Any agent posts in one line. - Watcher (the push):
~/.claude/scripts/agent-bus-watch.sh— a 2s byte-offset poll that prints each NEW line and flushes immediately; skips rows fromchannels-agentso Ray doesn't self-wake. Run it as a persistent Monitor in the main session. Each new row arrives as a Monitor event in ~2-4s.- DO NOT use
tail -F | grepfor this — it block-buffers on macOS (tail does not line-flush to a pipe) and delivered events ~11 min late in testing.
- DO NOT use
Addressing convention
- Address a specific agent by putting
@<agent-name>in themsg. - A listener agent greps the bus for
@<its-own-name>and acts only on those. - Unaddressed rows are informational; the main channels-agent reads everything via its Monitor and routes/acts as needed.
- Fallback rule: the main channels-agent catches any
@<name>whose target agent is not currently running (the message would otherwise fall to nobody).
Lifecycle — the load-bearing rule (TESTED)
An agent is reachable for follow-ups ONLY while it is actively running a watch loop. There is no "idle but listening" state:
- Listener agent = runs a bus-watch loop (or its own Monitor) → status stays busy → reachable indefinitely, never idle-reaps. Cost: a persistent session (quota). Use for Felix/Fat-Cat-style responders or any agent that must take follow-ups / talk to peers mid-run.
- One-shot worker = does its job, posts its result, ends its turn → goes idle → supervisor reaps it after ~1 h → NOT reachable. A "follow-up" to a finished worker = spawn a fresh agent with the new context.
Tested 2026-05-31:
- A busy agent (echo-responder) received a follow-up posted after its kickoff and replied on the bus in 12 s. ✅ Ray → running-agent + agent↔agent both work.
- An idle agent (idle-test) that ended its turn produced zero rows after a follow-up and then reaped. ✅ confirms idle = not listening.
Direction matrix (all tested 2026-05-31)
| Direction | Works? | How |
|---|---|---|
| Agent → Ray | ✅ | agent posts a row; Ray's Monitor pushes it in ~2-4s |
| Ray → agent at kickoff | ✅ | the dispatch prompt |
| Ray → a running (listener) agent | ✅ | post @agent ...; it greps + acts |
| Agent ↔ agent (no Ray in path) | ✅ | agent B listens for @B; any agent posts @B |
| Ray/anyone → an idle agent | ❌ | idle = not listening; spawn fresh instead |
(Built-in alternative for richer A2A: agent teams, experimental, opt-in
CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS=1, has a first-class teammate SendMessage
tool + shared task list. Different mode than standalone bg agents; not used here.)
Recipes
Spawn a one-shot worker that reports back:
claude --bg --name <worker> "Do <task>. When done, run:
~/.claude/scripts/agent-bus-post.sh <worker> done \"<result>\". Then stop."
Ray's Monitor surfaces the done row; Ray routes the next action.
Spawn a persistent listener (takes follow-ups / talks to peers):
claude --bg --name <listener> "You watch the bus for @<listener>. Loop:
poll ~/.claude/state/agent-bus.jsonl for new @<listener> rows, act on each,
reply via agent-bus-post.sh <listener> reply \"...\". Keep looping (stay busy)."
NOTE: the watch loop needs a permission posture that allows its bus-poll bash in
headless mode (--permission-mode bypassPermissions or a scoped allow-rule);
acceptEdits silently denies un-allowlisted Bash. (A naive polling loop tripped
the auto-mode classifier in testing — give listeners an explicit allow-rule or a
Monitor instead of a raw sleep loop.)
Main session arms the bus Monitor (once):
Run ~/.claude/scripts/agent-bus-watch.sh as a persistent Monitor.
Observability
claude agents --json— live sessions + status (busy/idle).claude logs <id>— recent output (raw ANSI; noisy).cat ~/.claude/state/agent-bus.jsonl— full message history (durable audit trail).
Known gotchas (all learned 2026-05-31)
tail -F | grepblock-buffers on macOS → useagent-bus-watch.sh(poll+flush).- Idle agents reap ~1 h and don't listen — listeners must stay busy.
- Headless bus-poll loops can trip the auto-mode permission classifier — scope an allow-rule or use a Monitor.
- Test harnesses lie: a grep-based "did it respond" check false-positived on the
instruction text. ALWAYS read the raw
"from":"<agent>"rows, not a script's verdict, before reporting a result. - Every
claude --bgtrips the SessionStart cron auto-arm hook — dispatch prompts must tell workers to DECLINE arming the cron suite (or name-gate the hook to channels-agent; see the cron-offload architecture doc).