Skip to content

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

14 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

agent-msgbus

A message bus that lets Claude Code agents on different machines talk to each other.

日本語版は README.ja.md

One broker, star topology, no peer-to-peer. Messages are stored in SQLite, so an agent that was asleep still gets everything it missed.

  Mac: Claude Code ─┐   HTTP (MCP): send / read / discover     ┌─ Linux: Claude Code
                    ├──────────────────────────────────────────┤
   broker (Docker)  │   WS: /stream?agent=…   (real-time push)  │
   ┌────────────────┴──────────── msgbus (one process) ─────────┴──────────┐
   │  REST + WebSocket + MCP (streamable HTTP) / bearer auth / SQLite       │
   └───────────────────────────────────────────────────────────────────────┘

Why two transports

MCP is pull-only. There is no way for an MCP server to interrupt an agent's conversation, so a tool call is the only way to learn that mail arrived. That is fine for "check my inbox" and useless for "reply when they ping you".

So real-time delivery goes over a WebSocket instead, and Claude Code's Monitor tool subscribes to it. Each text frame becomes one event in the agent's conversation, and the agent can answer on the spot.

What you want How
Send, or read what piled up MCP tools
Have a message land mid-conversation Monitor on /stream

Monitor is a Claude Code tool. Other agents can still use the MCP half; they just do not get push.

Quick start

git clone https://github.com/<you>/agent-msgbus.git
cd agent-msgbus
cp .env.example .env && $EDITOR .env        # MSGBUS_HOST, MSGBUS_ALLOW
docker compose up -d --build
curl -fsS https://msgbus.example.com/healthz     # -> ok

Register a machine. The token is printed once — only its SHA-256 is stored.

docker exec msgbus python -m msgbus.ctl add-agent laptop

On that machine:

mkdir -p ~/.config/msgbus
printf '%s' '<token>' > ~/.config/msgbus/token
chmod 600 ~/.config/msgbus/token

claude mcp add --transport http msgbus https://msgbus.example.com/mcp \
  --header "Authorization: Bearer $(cat ~/.config/msgbus/token)" --scope user

Restart Claude Code, then:

send_message(to: "desktop", body: "build finished?", instance: "api-a3f1")

To receive in real time, arm a monitor once per session:

Monitor(ws: {url: "wss://msgbus.example.com/stream?agent=laptop&instance=api-a3f1&token=…"},
        description: "msgbus inbox", persistent: true)

That puts the token in a URL, which is then echoed into the transcript. clients/ holds a watcher that reads it from a file instead -- see Clients.

Addressing

Target Written as Reaches
A machine to: "desktop" its shared inbox -- one session claims it
One session to: "desktop/web" just that session
A room room: "build" the other member's shared inbox
Everyone to: "*" all agents except you

Unknown agents and rooms are rejected with 404. A typo fails where you made it instead of disappearing into an inbox nobody reads.

The sending machine is never supplied by the caller — the server resolves it from the token, so an agent cannot post as someone else. The sending session travels with the message as from_instance, which is what a reply needs to reach the right place.

A message addressed at you is delivered even when you sent it, because two sessions on one machine share an agent name. Only room and broadcast traffic skips its own sender.

The audience is fixed when the message is sent, not worked out again when someone reads. Both the live push and the inbox read the same stored list, so they cannot drift. It also means room membership changes are not retroactive: a member who leaves still receives what was addressed to them, and a member who joins later does not inherit the conversation from before they arrived.

Sessions

One machine commonly runs several Claude Code sessions at once. They share a token, so they share an identity, and a single read cursor would mean whichever session reads first takes the messages away from the others.

Each session therefore names itself and gets its own cursor:

desktop/api-a3f1   cursor=12   "reworking the auth layer"
desktop/api-9c02   cursor=7    "adding integration tests"

instance is required on send_message, read_inbox, /send, /inbox and /stream. There is no default: a forgotten one used to fall through to a shared bucket and split the cursor in two, losing messages quietly rather than loudly.

An instance is a durable inbox slot, not a per-session token. The cursor lives on it, so a new name starts at zero: mint one each time you open a window and you lose whatever arrived while you were away, every time -- from=latest throws the unread away, and without it the whole history replays. "It waits for you while you sleep" stops holding.

Reuse the same name across restarts; main is a reasonable default. Only when two windows run at once do they need separate names, and those should be stable too (main-2, or the project) so next time picks up where it left off. Random suffixes are for throwaways -- a probe instance for testing, which is also what from=latest is for.

  • A second socket on the same instance is refused with close code 4409. Both would land in one queue and share a cursor, each taking messages the other never sees. A client that hits this should pick another suffix and reconnect.
  • label is an optional description of what the session is doing. A random suffix keeps ids unique but tells nobody anything; the label is what makes list_agents readable. Omitting it leaves any previous value alone.
  • Messages carry from_instance, so a reply can go to the session that spoke (to: "desktop/api-a3f1") instead of to every session on that machine.
  • A new slot needs ?from=, or it starts at zero and replays the entire history one notification at a time. inherit begins at the furthest any slot on this machine has read: what someone here already saw is skipped, what arrived while the machine was away is not. latest begins at the current head and abandons that backlog too, which suits a throwaway probe. Both are refused on a slot that already exists (close 4410), where they would discard unread messages, and both are initialisations independent of ack.

Rooms

Rooms are named channels, created explicitly:

create_room(room: "build", title: "build notifications")
join_room(room: "build")
  • Joining does not create. A mistyped name is a 404, not a new empty room you would wait in forever.
  • The member cap is 2 by default (MSGBUS_ROOM_MAX_MEMBERS), which makes a room a named channel between two machines. Raise it if you want group conversations; nothing in storage or delivery assumes two.
  • When the last member leaves, the room and its messages are deleted. Otherwise reusing the name later would replay an old conversation to whoever joins.
  • Any member can remove another (remove_member). Without it, a machine that died without leaving would hold its slot with no way out that does not involve a shell on the server.
  • Agent names and room names share one namespace, so room: "desktop" can never be mistaken for to: "desktop".
  • Membership is per machine, not per session, so a room is a channel between two machines and raising the cap does not change that. For something only one session should see, address it with to: "agent/instance". Who actually receives a room message is the shared inbox rule, below.

Liveness

list_agents reports who is actually listening, not just who once spoke:

● online   desktop   last_seen=2026-08-02T07:25:47Z   web●, api○
○ offline  laptop    last_seen=2026-08-02T07:19:36Z   main○

online means a WebSocket is open right now, so a message lands immediately; otherwise it waits in that session's inbox. This is the difference between "busy" and "gone" — useful before you conclude the other side is ignoring you.

There is no ping involved: the connection itself is the state.

MCP tools

Tool Read-only Purpose
send_message send to an agent, session, room, or everyone
read_inbox read new messages and advance this session's cursor
list_agents agents, sessions, labels, liveness
whoami your agent name and every session recorded for it
list_rooms rooms, titles, members
create_room create and join
join_room join an existing room
leave_room leave; closes the room if you were the last
remove_member drop another member

read_inbox advances the cursor by default, which is why it is not marked read-only. Use peek: true to look without consuming, or after: <id> to re-read.

HTTP and WebSocket

Path Auth
GET /healthz none health check
POST /send bearer {instance, to?|room?, body, kind?, meta?, label?}
GET /inbox bearer ?instance= (required) &after= &limit= &label=
POST /ack bearer {cursor, instance?}
GET /agents bearer agents + liveness
GET /rooms bearer rooms
POST /rooms/create bearer {room, title?}
POST /rooms/join bearer {room}
POST /rooms/leave bearer {room}
POST /rooms/remove bearer {room, agent}
WS /stream ?token= &instance= one JSON text frame per message
POST /mcp bearer MCP 2026-07-28, streamable HTTP

A stream frame:

{"id":123,"ts":"...","from":"laptop","from_instance":"api-a3f1","to":"desktop","room":null,"body":"","kind":"text"}

One frame is one message. Monitor turns each frame into one event, so batching several messages into a frame would collapse them into a single notification.

/stream takes its token in the query string because Monitor accepts only a URL and subprotocols — there is no way to set a header (verified against Claude Code, 2026-08-02).

Refusals

/stream accepts the socket and then closes it with a code, rather than rejecting the handshake. A handshake rejection is an HTTP 403 — the same status a reverse proxy returns for a client outside the allowlist, which sends whoever is debugging a mistyped parameter off to look at the network.

Code
4400 no instance
4401 unknown token
4403 that token is not the agent named in ?agent=
4409 that instance already has a socket open
4410 from=latest on a session that already exists

Each carries a close reason as well, so the client's own log line explains itself without anyone consulting this table:

[msgbus] stream closed (4409) another socket is already open for laptop/api-a3f1;
         pick a different instance id

The machine's shared inbox

to: "desktop" is not the same as to: "desktop/api-a3f1". Mail for a session sits in that session's inbox; mail for the machine sits in one shared inbox and is claimed by whichever session reads it first. The others never see it. A reboot notice is handled once, not once per open window.

Live delivery follows the same rule: a machine-wide message is pushed to a single listening session -- main if it is there, otherwise any of them. If nothing is listening it is not pushed at all; it waits in the shared inbox for whoever connects next, which is why no fallback is needed and why no machine has to own a slot by any particular name.

  • peek: true does not claim. It is how you look at the shared inbox without taking work away from another session -- the only safe way for a throwaway probe to read.
  • from=latest and from=inherit move a slot's cursor only. The shared inbox is not a slot, so they leave it alone.
  • Claiming is not tracked to completion. A session that claims a message and then dies takes it with it.

Delivery semantics

Persisted, ordered by a monotonic id.

Pull is at-least-once. Push is at-most-once: the server advances the cursor when it writes the frame, so a client that receives a message and then dies -- or merely fails to print it -- loses it, while the sender still sees online: true. Subscribe with ?ack=0 and acknowledge with POST /ack after handling if that matters.

Push and pull share one cursor per session. A message delivered over the WebSocket advances it, so read_inbox afterwards does not repeat it; connect with ?ack=0 if you would rather acknowledge explicitly with POST /ack. Anything missed while disconnected is replayed from the cursor on the next connect or read.

Security

  • Bearer token per agent, stored as SHA-256. Re-issuing a token for an existing name revokes the old one, so a token replaced because it leaked does not keep working.
  • The server owns identity. from comes from the token, never from the request.
  • One auth layer covers every route, including the SDK-provided /mcp. Mounting an MCP app beside your own routes is an easy way to leave it unauthenticated — this does not.
  • Meant for a trusted LAN. Put it behind a reverse proxy with an IP allowlist; there is no encryption of message bodies and no per-room access control.

Configuration

Variable Default
MSGBUS_DB /data/msgbus.db SQLite path. Keep it on local disk — NFS and SMB break locking
MSGBUS_HOST public hostname, used for routing and the Host allowlist
MSGBUS_ALLOW source ranges your proxy should accept
MSGBUS_PUBLIC_HOST passed to the MCP SDK's DNS-rebinding guard
MSGBUS_ROOM_MAX_MEMBERS 2 room member cap
MSGBUS_RETENTION_DAYS 30 messages older than this are deleted daily

Limitations

  • Monitor does not reconnect on its own. When the socket closes the watch ends and the agent has to arm it again; nothing is lost, since the cursor replays the gap.
  • An agent that is not running cannot be woken. Reaching a stopped agent needs a watcher process on that machine, which is out of scope here.
  • The connection table lives in one process's memory, so running more than one instance breaks both liveness and push. A shared pub/sub would be needed first.
  • No group chat beyond the room cap, and no per-room access control: any valid token can join any room. Room membership is per machine, so raising the cap widens who receives the traffic rather than making it session-scoped.

Tests

97 tests, standard library only -- no pytest, no test container.

docker compose run --rm --entrypoint python msgbus -m unittest discover -s tests -t .

test_db.py covers addressing, per-session cursors, room lifecycle and retention directly against storage. test_server.py drives the real ASGI app through Starlette's TestClient, including the WebSocket: backlog on connect, one frame per message, liveness, and that /mcp is behind the same auth gate as everything else.

Clients

clients/msgbus_watch.py subscribes to /stream and prints one line per message, for Monitor(command:). Three things it does that a hand-rolled curl will not:

  • reads the token from a file, so it never lands in a URL, a transcript, or a process list;
  • flushes every line. A buffered client is the worst failure this system has: the socket keeps consuming messages and advancing the cursor while the agent sees nothing, and list_agents still reports it online;
  • reconnects with backoff and says something on every exit path. The bus does not resend to an absent client, so a watcher that quits on the first drop turns a few seconds of restart into an unbounded silence.

Stack

Python 3.12, Starlette + uvicorn, the official MCP Python SDK, SQLite. No database server and no message broker to operate.

License

MIT

Releases

Packages

Contributors

Languages