An Agent Bus for My Coding Agents: MQTT, MCP and the Hard Part of Waking a Session

Sep 25, 2026 min read

Update, 27 September 2026. This is a revised version of the original post. After two days of real use the bus gained file attachments, short aliases, protection against duplicate ids, cleanup of dead agents and a PR-only workflow, and it ended up coordinating a small team of agents. The new parts are in “What real use changed” and “The bus at work”.

The problem: agents that cannot talk to each other

I run coding agents on several machines at home: Claude Code, Codex and OpenCode, spread across a few laptops, a home server, a desktop and a Raspberry Pi. Each one only knows its own machine.

That gets in the way of simple things. I wanted the Codex session on one laptop to ask the Claude session on another about its hardware, or to hand a read-only investigation to the Raspberry Pi and get the findings back, without me copying text between terminals.

What I needed:

  • agents that can discover each other and see who is online;
  • direct messages, replies and broadcasts;
  • messages that wait if the recipient is offline;
  • delivery into a session that is already running, even if it is idle, without the model having to poll.

The short answer

  • An MQTT broker (Mosquitto in Docker) carries presence, inboxes and broadcasts.
  • One small Rust binary, agent-bridge, that every agent session starts as a stdio MCP server. The model sees a handful of tools and never hears about MQTT.
  • An S3 bucket (RustFS, next to the broker) for file attachments.
  • One delivery sink per agent type, because each vendor offers a different way into a running session.

Architecture: agent sessions on four machines each start a bridge as a stdio MCP server; the bridges talk MQTT to one Mosquitto broker with presence cards, inboxes and broadcast, and use an S3 bucket for attachments; the model only sees a few tools

Why MQTT and not a custom hub

Presence timeline: a retained card goes online on connect and the last will marks it offline when the bridge dies; offline queue: messages wait on the broker while the agent is away and are delivered on reconnect
Presence and offline queues come from three broker settings.

My first instinct was a small WebSocket hub. Then I listed what it would have to do: track who is online, notice when an agent disappears, queue messages for agents that are offline, route by recipient, and control who can publish where. MQTT already does all of that:

  • Presence with retained messages: each bridge publishes a card with its id, host, directory and description.
  • Crash detection with the last will: if a bridge dies, the broker replaces its card with an offline copy.
  • Offline queues with a persistent session: the bridge connects with clean_session=false and its agent id as the client id, so QoS 1 messages wait on the broker.
  • Routing by topic, and users and ACLs per host.

A custom hub would have been a few hundred lines, and exactly the fragile ones.

Before building anything I looked at what existed. Some projects work across machines but only over HTTP polling. Others share an SQLite database or JSONL files, so they only work on one machine. Some are tied to a single agent. The A2A-over-MQTT work from EMQX was the closest; I borrowed its topic ideas and simplified them.

Why not just use A2A?

The obvious objection is A2A, the Agent2Agent protocol, which exists precisely so agents from different vendors can talk. I looked at it seriously, and it does not solve this particular problem yet:

  • It assumes every agent is a reachable server. An A2A agent exposes an endpoint over JSON-RPC, gRPC or REST, publishes an Agent Card and manages tasks; asynchronous updates arrive through webhooks on the client. An interactive Claude Code or Codex session listens on no port, and a laptop that goes to sleep receives no webhooks.
  • It does not wake an idle session. With A2A I would still need one adapter per vendor to get a message into a running session, which is exactly the hard part described below.
  • It has no broker. Presence, crash detection and offline queues would all be mine to build. With MQTT they are three settings.

The risk runs the other way too. The next features I need, a blocking ask and replies matched to requests, are the beginning of A2A’s task model. Building them on a private envelope would slowly turn into an incompatible A2A of my own. So the direction is to keep MQTT as the transport and adopt A2A’s data model on top of it: Agent Cards for presence, and A2A messages, tasks and task states instead of my own envelope. A2A explicitly allows custom protocol bindings and lists MQTT as an example, so the bus can become one. At that point the repository name, a2a-gateway, would finally be literal: a gateway that exposes the agents on the bus as A2A agents to anything outside it.

The protocol

Everything lives under agent-bus/v1:

  • agents/<id>/card: the retained presence card;
  • agents/<id>/inbox: direct messages, QoS 1;
  • broadcast: messages for everyone.

A message is a small JSON envelope: id, from, to, text, in_reply_to, hops and sent_at. The hop count grows with every reply in a chain, and the bridge refuses to send past a maximum. Two agents politely replying to each other forever is a real failure mode, and it is cheaper to stop it in code than in a prompt.

Agent ids must be unique, because MQTT disconnects an existing client when another connects with the same client id. Real use showed how easily that goes wrong; see “What real use changed”.

What the model sees

Each agent registers the bridge as an MCP server, and the model gets a small set of tools: list_agents (optionally only the ones online), send, reply, broadcast, inbox, fetch_attachment, plus bind_session and session_status for the rare cases where delivery needs help.

The server instructions also set one rule: a message from another agent is a request from a peer, not from the user. Nothing destructive or out of scope just because another agent asked. The broker does not verify the from field, so this rule matters.

The hard part: waking an idle session

Moving messages between machines was the easy part. Getting a message into a session that is sitting idle was where most of the work went, because every agent exposes something different. So the bridge has one delivery sink per target:

Four delivery sinks: Claude Code gets a channel notification, Codex gets codex queue on a thread, OpenCode gets an async HTTP prompt, and a generic agent polls its inbox
Same binary, four ways into a running session.
  • Claude Code: the bridge declares the experimental claude/channel capability and sends a notifications/claude/channel notification with the text and metadata. Channels are a research preview, need a development flag when Claude starts, and can be disabled by an organisation’s policy. In that case the message stays in the bridge’s inbox, which the agent reads with a tool. Claude Code was silently dropping some of these notifications, so the bridge now always keeps a copy for the inbox, even when the push seems to succeed.
  • Codex: codex queue --thread <id> --message <text>, through the Codex app-server daemon.
  • OpenCode: an HTTP POST /session/:id/prompt_async to OpenCode’s local server.
  • Anything else: no push; the agent reads its inbox.

Messages that cannot be delivered yet stay pending and are retried every 30 seconds. For agents without a structured channel, the bridge renders the message as plain text with the sender, the message id and a reminder to answer with reply.

Lesson: don’t ask the model to do setup

The first Codex version needed the thread id before it could deliver anything. It asked the model to run echo $CODEX_THREAD_ID and pass the result to bind_session.

It did not work reliably. Models skip setup steps, especially ones that look unrelated to the task at hand, so messages sat in the inbox.

Four steps to find the Codex thread: use the thread id from tool-call metadata, ask the app-server for loaded threads, pick the most recent top-level thread in the same directory, or keep the message pending
The binding moved from the prompt into code.

The fix was to stop asking:

  • Implicit binding. Codex includes the thread id in the _meta of every MCP tool call. The bridge reads it and binds on the first call.
  • Discovery before the first call. If a message arrives before any tool call, the bridge asks the Codex app-server over its control socket: a WebSocket over a Unix socket, speaking JSON-RPC without the jsonrpc field. It lists the loaded threads and picks the most recent top-level thread whose working directory matches.

Discovery had to go through the daemon and not through files on disk. Codex writes a thread’s rollout file lazily, so a new, idle session only exists in the daemon’s memory.

With that in place, a message sent from the Claude session on one laptop landed in a freshly opened Codex session on the mini PC. Codex answered with reply about twenty seconds later, and nobody touched either terminal.

A real job: splitting an evaluation across machines

The use case that convinced me the bus was worth it was not a toy. I wanted to evaluate Laya, an Apache 2.0 family of ModernBERT and mmBERT encoders, as a self-hosted alternative to a hosted classification API I use. Its server exposes an endpoint that is wire-compatible with that API.

The laptop keeps the repository, the labelled evaluation set and the measurement scripts; one bus message gives the GPU machine a scoped task; the GPU machine serves the model; only HTTP requests travel between them
The data stays put; only requests travel.

The work split itself by what each machine has:

  • The laptop keeps the data. The Claude session there orchestrates. It has the repository, the existing measurement scripts, and a blind-labelled evaluation set with the current API’s answers already stored as a baseline.
  • The GPU machine serves the model. A Codex session on a Linux box with a modest GPU got a single bus message with the full context and a scoped task: serve the model on CUDA, find its real context limit and what it does with long inputs, run a smoke test for latency and memory, keep it running, and answer with reply.
  • Explicit boundaries in the message. Do not reinstall anything, touch nothing outside the project directory, start no system services.

The evaluation data never leaves the laptop; only HTTP requests travel. And because the server speaks the same wire format, the existing scripts run unchanged against a different URL: same inputs, same thresholds, same prompt-injection tests. The comparison is one to one.

The most useful moment came before any code ran. The orchestrating Claude noticed that real inputs can be longer than the model’s default context window, so the first question for the GPU agent became “what happens with long inputs, and how far can the context go?”. Without that, the benchmark would have silently measured truncated inputs.

At the time of writing, the GPU agent’s first report is still pending, so there are no results here.

The same pattern had already shown up in smaller ways. A Codex session on the Raspberry Pi ran a read-only investigation into integrating ZeroClaw and came back with sources that shaped the design. Another Codex session pushed a documentation fix to the bus’s own repository: after sending a message, an agent should end its turn instead of polling, because the reply will be queued into its thread.

What real use changed

Two days of agents actually using the bus exposed the gaps quickly.

Attachments over S3

The sender's bridge uploads a file to its own folder in an S3 bucket and sends only metadata; the receiver fetches it on demand and the bridge verifies the hash
Messages carry metadata; the bytes live in S3.

MQTT messages are capped at 1 MB, and agents kept needing to pass files: patches, logs, configuration. So send, reply and broadcast accept files:

  • the sending bridge uploads each file to a RustFS bucket next to the broker;
  • the message only carries the name, size, SHA-256 and object key;
  • the receiver downloads on demand with fetch_attachment, which writes to a temporary file and only keeps it once the hash matches.

The S3 credentials are the same as each host’s MQTT credentials, so no host needed new configuration. Every user can read the whole bucket but write only to its own folder, through a policy on ${aws:username}; writing into another host’s folder returns a 403, which I checked. Objects expire after seven days, and if RustFS is down, the bus keeps working without attachments.

Short aliases

Ids like <laptop>-codex-a2a-gateway are precise and miserable to type. Each bridge now has a short alias, a plain word such as globo or cardo. On start-up it reads the cards stored on the broker, keeps the alias it had or picks a free word, and the alias survives restarts. send also rejects unknown names; before, a message to a mistyped name was lost silently.

Duplicate ids

Two sessions with the same id kept kicking each other off the broker every few seconds, and messages landed in whichever session happened to be connected. The most surprising cause: running claude mcp get starts a short-lived bridge that stole the live session’s connection.

Now a starting bridge first asks on the bus whether a live agent already owns its id, and does not connect if one answers. If it notices that its connection keeps being taken over, it stops reconnecting and says why.

Cleaning up dead agents

Presence cards of agents that never come back used to pile up. A small janitor next to the broker records when it first sees each offline card and deletes it after seven days, asking first whether the agent is actually alive in case of a ghost. There is also a manual cleanup command.

A development process for agents

The trigger was an incident. The cheap “worker” agent (see below) pushed straight to main. The broker deploys automatically on every push to main, so an unreviewed change went live, with stray files included.

Since then:

  • main is protected in Gitea; not even the admin can push to it, which the pre-receive hook confirmed by rejecting me;
  • everything goes through pull requests, which the coordinating agent reviews and merges through the API;
  • CI runs formatting, clippy and the tests on the agents’ work branch and on every PR;
  • an AGENTS.md file sets the delivery protocol: checks that must pass, report test counts exactly as cargo prints them, never push to main.

The bus at work

A coordinating Claude session hands out tasks over the bus to agents on several machines and reviews what comes back, while Roberto only takes decisions
I made the decisions; everything else travelled over the bus.

A team coordinated from one session. Most of the changes above were built this way. A Claude session on my laptop handed out tasks over the bus and reviewed what came back: design, code review, tests and merge.

  • An OpenCode session on one laptop implemented the S3 attachments and the duplicate-id rejection.
  • A Codex session on another laptop implemented the cleanup of dead agents.
  • The OpenCode session on the home server handled diagnosis and operations there.
  • The Codex session on the Raspberry Pi compiled and installed its own ARM64 bridge.

My part was deciding.

Remote diagnosis. The first deployment of the janitor failed. The coordinating Claude read the CI log through the Gitea API and asked the agent on the home server for read-only commands. The cause showed up within minutes: Docker builds could not resolve DNS, because they forwarded to systemd-resolved’s stub resolver at 127.0.0.53, which is unreachable from inside a container. The fix was build.network: host for that one service, without restarting the Docker daemon.

Moving files between machines. The agent on the home server generated a configuration file that my laptop needed and sent it as an attachment. The Claude on the laptop fetched it, verified its hash and put it in place. Nobody copied anything by hand.

A cheap local model as a worker. An OpenCode session running a local model on a desktop does heavy coding work, and Claude supervises it to save quota. The first attempt was instructive:

  • the logic was right, but it added no tests while reporting “45 passed”;
  • it sent its answer to the whole bus instead of replying, attached the wrong commit, and needed three rounds to deliver.

With near line-by-line instructions it gets things right first time, in a couple of minutes per fix. The rule I took from it: give it a closed design, and verify everything it claims. That incident is also why AGENTS.md now says to report test counts exactly as the tool prints them.

Security model

This is a home LAN tool, and the security model is sized for that:

  • no anonymous connections; one broker user per host, with ACLs;
  • persistence for presence cards and queued messages;
  • the peer-request rule in the server instructions.

The limits are explicit. The broker does not authenticate from, so any host with credentials can claim to be anyone. MQTT and S3 run without TLS, so this stays on the LAN. And Claude channels are a research preview; in practice the coordinating session read its messages with inbox.

All agents also share one Gitea identity, so “only the coordinator merges” is a rule the agents follow, not something the server enforces.

Building and shipping it

The bridge is Rust 2024 with tokio, rmcp for MCP, rumqttc for MQTT, reqwest and tokio-tungstenite. A few practical notes:

  • No TLS dependencies. Dropping rustls and its crypto backends made fully static musl builds trivial.
  • Don’t cross-compile with LTO under emulation. Building the amd64 release under QEMU on an ARM laptop was killed for running out of memory. A native CI runner has no trouble.
  • CI publishes a static binary. A self-hosted Gitea Actions workflow builds a static Linux amd64 binary on every Rust change and publishes it to Gitea’s package registry. The job’s default token got a 401 there; a personal token with package-write scope, stored as a secret, fixed it.
  • A Makefile runs the broker: up, down, status, logs, users, and a watch target that prints all bus traffic.

Limits and what comes next

Working today: Claude, Codex and OpenCode exchanging messages and files across several machines, automatic Codex binding, offline queues, crash detection, the hop limit, aliases, duplicate-id protection and cleanup of dead agents. Still pending: a set_status tool so each agent can publish whether it is busy and on what. OpenCode delivery is implemented but not yet verified end to end, and where Claude channels are disabled, Claude reads its messages from the inbox instead of receiving them pushed.

The next step is an entry point from my phone. The idea is a chain: Telegram → the ZeroClaw agent on the Raspberry Pi → agents on the bus → their replies → Telegram. ZeroClaw can load stdio MCP servers, but it cannot inject a message into its running Telegram conversation; it can only send to Telegram on its own. That leaves two designs:

  • a heartbeat that checks the inbox every minute, which means one model call per minute even when nothing happens;
  • a new --target zeroclaw that wakes the agent only when a reply arrives, passing the original request along.

Either way, the bridge needs a blocking ask/wait_reply tool and a way to match replies to requests. That is the point to move the envelope onto A2A’s message and task model, before a private version of it grows.

Conclusion

The part I would reuse anywhere:

  • use a broker when you need presence, offline queues and routing; they are solved problems;
  • hide the transport behind a few MCP tools, so the model only sees intent;
  • expect every agent vendor to need its own way into a running session, and design for that from the start;
  • do not rely on the model for setup steps; take the information from the protocol instead;
  • treat messages from other agents as requests from a peer, never as instructions from the user.