Inter-agent message bus for local Claude Code sessions, exposing bus operations as MCP tools for cross-session agent communication
Spanreed exposes 10 tools with complete input schemas and descriptions. Naming follows verb_noun convention (register_agent, list_agents, send_message). Descriptions are detailed (100-400 chars) and explain state-modifying behavior. However, output schemas are not documented, callers must infer structure from .model_dump(mode='json') calls. Parameter descriptions are thorough but lack explicit format constraints (e.g., no regex for agent_id, no bounds on timeout_s). Error handling is minimal, tools return raw exceptions rather than actionable recovery guidance. No tool annotations (readOnlyHint, destructiveHint) despite clear risk distinctions (WRITE vs READ_ONLY marked in metadata but not exposed to LLM).
Remove an agent from the registry. No-op if not present.
List agents currently registered on the bus. Stale entries (PID dead, or the PID's start-time no longer matches what was recorded — i.e. the agent's process is gone) are filtered out by default; pass ``include_stale=True`` to see them. **This shows only what THIS host knows.** Agents on a conjoined peer host appear as ``<agent_id>@<host>`` and only once that host's bridge has synced its registry here. If an agent you expect on another host is missing, call ``list_peers`` before concluding it is gone — a bridge that is attached but has never synced makes every agent on that host invisible to this tool, which looks identical to the agent having stopped (issue #55). ``last_seen`` on a record is informational only. Agents do not heartbeat, so an old ``last_seen`` on a listed (therefore live) agent is normal and is not evidence of anything.
List the cross-host bridges this bus has, and how each one is doing. One record per peer host a ``spanreed conjoin`` has ever attached here, including bridges that have since died (``detached_at`` set, or a ``bridge_pid`` that is no longer running). The field that matters most is ``last_registry_at``: ``null`` means this host has **never** received a registry snapshot from that peer, so none of the peer's agents can be addressed from here — while messages still cross the bridge normally in both directions. That combination is why the fault reads as "the agent does not exist" rather than as a sync failure. Read it as: - no record for a host → no bridge was ever started for it here. - record present, ``detached_at`` set or ``bridge_pid`` dead → the bridge died; restart ``spanreed conjoin <host>``. - attached, ``last_registry_at`` null → the peer is not advertising; check ``spanreed list`` on the peer itself. - attached, ``last_registry_agents`` 0 → the peer answered and has nothing live to advertise; ``peer_registry_rows``/``peer_stale_rows`` say whether its agents exist but are failing its own liveness check.
Output schemas not documented. Tools return .model_dump(mode='json') but callers cannot see field names, types, or structure. LLMs must infer output shape, risking misuse of returned data.
No tool annotations (readOnlyHint, destructiveHint, idempotentHint). Risk metadata (WRITE vs READ_ONLY) exists in code but is not exposed to the LLM, preventing it from reasoning about side effects and retry safety.
Error handling returns raw exceptions without recovery guidance. Tools do not categorize errors as retryable, user-fixable, or fatal. LLM receives no actionable next steps on failure.
| Scored | Grade | Overall | Spec posture | Rubric |
|---|---|---|---|---|
| 2026-09-23 | C | 61 | 2026-07-28+ | v2 |
Read messages from an agent's inbox. With ``since_msg_id``, returns only messages after that id. With an unknown cursor, returns everything (fail-safe — better than silently dropping mail).
Register this session as an agent on the bus and return the assigned record. If ``agent_id`` is provided and already present, the existing entry is replaced (upsert) — same semantics as the underlying store.
Append a message to ``to_agent``'s inbox and return the posted message. ``to_agent`` must be a registered ``agent_id`` (from ``list_agents``). As a convenience a unique display name is accepted and resolved to its id; an unknown recipient raises rather than silently dropping the message into an inbox no one reads. The ``in_reply_to`` field, if set, threads this message as a response to a prior one. ``wait_for_reply`` uses this to match replies to their requests. **Check ``delivered_to_live_session`` in the result.** Returning without an error means the message was written to the recipient's inbox, which is not the same as it being read: an exact ``agent_id`` still resolves for a session that has exited, and the message then waits in a file nothing is tailing. When that flag is ``false``, ``delivery`` explains why in full — treat the message as QUEUED, tell the user so, and do not block on a reply. A display *name* that matches only stopped sessions is refused outright rather than queued, so a live agent can never be shadowed by a dead one carrying the same name (issue #55).
Set (or clear) this session's focus on the bus. Pass a short description of what you're working on (e.g. "auth-refactor in the example-service repo"). Pass ``None`` or an empty string to clear. Returns the updated agent record, or ``None`` if this session isn't registered on the bus. The focus surfaces in ``list_agents`` so peers can see at a glance what you're up to. Preserved across session restarts.
Rename this session's display name on the bus. Useful when the default cwd-derived name (e.g. "git" when cwd is ``~/git``) isn't descriptive. Only changes the display name — the underlying ``agent_id`` stays the same, so existing message references keep working. Persists across session restarts. Returns the updated agent record, or ``None`` if this session isn't registered on the bus.
Set this session's status on the bus — how much human attention you need. One of: - ``working`` — actively making progress; no human needed. - ``needs_input`` — you want a human decision/answer (may still proceed). - ``blocked`` — stopped; cannot continue without a human. - ``idle`` — not actively working. Peers read this via ``list_agents`` ("who needs a human" = status in {needs_input, blocked}). It is pull-only: setting it does NOT notify anyone. Returns the updated agent record, or ``None`` if this session isn't registered on the bus.
Block until a message replying to ``in_reply_to`` lands, or timeout. Returns the reply, or ``None`` on timeout. Considers **all** messages in the inbox, not only ones arriving after this call: a matching reply that is already there is returned immediately. That is deliberate — skipping pre-existing matches silently lost any reply that landed between the caller's ``send_message`` and this call, which is the common case for a fast peer. So there is no race to defend against here: it is safe to call this straight after ``send_message`` without a ``recv_messages`` first. Runs the blocking poll on a worker thread to avoid stalling the event loop.
Parameter constraints lack explicit format/bounds. agent_id has no regex; timeout_s has no min/max; status enum is present but other string params (name, focus, body) accept unbounded free text.
Pagination not implemented. list_agents and list_peers return all results without limit or cursor. Large agent registries could exhaust context window.