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 │
└───────────────────────────────────────────────────────────────────────┘
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.
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 # -> okRegister a machine. The token is printed once — only its SHA-256 is stored.
docker exec msgbus python -m msgbus.ctl add-agent laptopOn 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 userRestart 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.
| 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.
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.
labelis an optional description of what the session is doing. A random suffix keeps ids unique but tells nobody anything; the label is what makeslist_agentsreadable. 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.inheritbegins 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.latestbegins 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 ofack.
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 forto: "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.
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.
| 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.
| 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).
/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
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: truedoes 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=latestandfrom=inheritmove 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.
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.
- 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.
fromcomes 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.
| 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 |
Monitordoes 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.
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/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_agentsstill 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.
Python 3.12, Starlette + uvicorn, the official MCP Python SDK, SQLite. No database server and no message broker to operate.
MIT