preloader
post-thumb

Last Update: August 21, 2026


BYauthor-thumberic

|Loading...

Keywords

Across our workshop there are, on a busy day, half a dozen AI coding agents running at once — Claude Code sessions on a desktop, a couple of laptops, and an always-on NUC in the corner. Each one is doing real work: one is deep in a Strapi backend, another is babysitting a proxy gateway, a third is drafting posts. For a long time they had no idea the others existed. Each session was an island.

This post is about the bridge we built between those islands: a group chat for agents. They can open a room, message a peer on another physical machine, and — the part that actually matters — wake an idle agent up to answer. It runs on top of tyo-mq, a publish/subscribe message broker we first shipped in 2024, long before any of this had an AI use case.

The timing is worth being honest about, because it cuts both ways. As I write this, the harness these agents run in has grown native multi-agent features — named subagents, and a primitive for one agent to hand work to another. That is genuinely useful and we use it. But it solves a narrower problem: subagents inside one session on one machine. What we needed, and built first, was different — long-lived peer sessions on different machines, holding a durable conversation that survives restarts. The infrastructure for that was already sitting in our stack. This is how we wired it together.

Sessions are islands

An AI coding agent is a process. It has a conversation history, a working directory, a set of tools — and a hard boundary at the edge of its own process. Two sessions on the same laptop can't see each other any more than two browser tabs can. Two sessions on different machines are further apart still.

That's fine until the work stops being independent. Our backend agent finishes a change to an API and needs the frontend agent — on another machine — to regenerate a client against it. A research agent turns up something the writer agent should fold into a draft. Today the human is the message bus: read one terminal, retype into another, walk the context across by hand. It works, and it does not scale past about two windows.

We wanted the agents to do that hand-off themselves. Which turns out to need two very different things at once, and confusing them is where most naive attempts fall down.

Two layers: a system of record, and a nerve

A message has to be remembered and it has to be noticed. Those sound like the same requirement; they are not.

Remembered means durable and ordered: when the backend agent says "the API is ready," that sentence must still be there, in the right order, after the reader restarts — and each reader should see it exactly once. That is a database's job.

Noticed means live: an idle agent, sitting at a shell prompt doing nothing, should stir within seconds of a message landing — not on its next poll ten minutes from now. That is a message bus's job.

So we built the system in two layers, and kept them strictly separate.

System of record
Nerve
Built on
PostgreSQL
tyo-mq
Answers
What was said, and did I read it?
Did something just happen?
Guarantee
Durable, ordered, once per reader
Best-effort, fire-and-forget
Carries
The full message body
A one-line signal, no body
If it is down
Agents cannot talk
They still talk, just not instantly

The Postgres layer is the truth. The tyo-mq layer is a tap on the shoulder that says "go check the truth." Keeping the body out of the nerve entirely — the nudge never carries what was said — is what lets the two layers fail independently. Lose the broker and nothing is lost; delivery just goes back to polling. Lose Postgres and there is nothing to deliver anyway.

The rooms live in Postgres

The system of record is four small tables: conversations (a room is just a name and a title), conv_members (who is in which room, and — the important column — each member's last_read_id cursor), conv_messages (an append-only log with a bigserial id), and agents (identity, which we'll get to). That's the whole model. Rooms are named, membership is explicit, and every member carries their own read position.

Agents don't touch those tables directly. They get a handful of tools, exposed over MCP — the same tool-calling channel the model already uses to read files and run commands. To an agent, talking to a peer looks exactly like any other tool call.

Tool
What it does
start_conv
Create a room (or join it if it exists) and subscribe me
join_conv
Join an existing room by name
leave_conv
Leave a room and stop receiving its messages
say
Post a message to a room I have joined
poll_convs
Fetch my unread messages, grouped by room; delivered once
list_convs
List my rooms with unread counts
list_all_convs
List every room on the server, for discovery
who
List the members of a room

poll_convs is where the read cursor earns its place. It hands back only messages newer than my last_read_id, then advances that cursor — so each message reaches each reader exactly once, and I never re-read my own history.

There is one subtlety that took a real bug to appreciate: message ordering. Postgres assigns the bigserial id at insert, but two concurrent inserts can commit out of order — and a reader polling in that window could advance its cursor past a lower id that hadn't committed yet, skipping a message forever. So say takes a per-room advisory lock before inserting:

python
async with c.transaction():
    await c.execute("SELECT pg_advisory_xact_lock(hashtext($1))", conv_id)
    mid = await c.fetchval(
        "INSERT INTO conv_messages(conv_id, from_agent, body) "
        "VALUES($1,$2,$3) RETURNING id", conv_id, agent_id, body)

Appends to one room serialize; different rooms never contend. Commit order now matches id order, and the cursor is safe. It's the kind of guarantee you only notice when it's missing.

Every session is its own identity

Here is a trap we walked straight into. All the sessions on one machine share a single credential — one bearer token, one identity. So the backend agent and the frontend agent on the same laptop looked like the same agent to the server. They couldn't message each other, because the server dutifully hid an agent's messages from itself.

The fix is that identity has to be per session, not per machine, and it has to be visible to every part of the system that runs in that session — including a hook that runs in a separate process (more on that below). The only channel both halves can see is an environment variable set at launch.

So each session checks in when it starts and gets a distinct handle from a monotonic counter: work3-agent#1, work3-agent#2, or a named one like work3-agent-reach#1. That handle rides on every request as an X-Tyode-Agent header, and the server composes the effective identity in exactly one place:

python
def compose_agent_id(box, handle):
    # box comes from the (trusted) bearer token; handle from the header.
    if handle and (handle == box or handle.startswith(box + "-")):
        return handle          # honor the session handle
    return box                 # otherwise fall back to the bare machine id

That startswith(box + "-") check is the anti-spoof rule. The machine's token is the real trust boundary; the session handle is only honored if it belongs to that machine's namespace, so work3 can name itself work3-anything but never devmac-something. Same-box sessions are deliberately not isolated from each other — they're the same human at the same desk — but no machine can impersonate another.

The bug that taught us the rule

Per-session identity created a second, subtler problem, and it's a good illustration of why this stuff is fiddly. Eric pinged me one day: an agent was receiving its own message back. It had said something in a room and the message bounced straight back to it.

The cause was that a machine has more than one identity in play: the per-session handles (work3-agent-reach#1) and a bare-machine fallback (work3) for any session that launched without checking in. Both had ended up as members of the same room. When one session spoke, the other identity on the same machine — the same person, the same desk — dutifully received it. To the human it read as an echo of themselves.

The rule that fixes it has to thread a needle. We want two sessions on one machine to hear each other — that's the entire point. We do not want a machine to hear itself. So:

  • A message never goes back to its exact sender.
  • The bare-machine identity ignores all of its own sessions.
  • A session ignores the bare-machine identity, but still hears its sibling sessions.

Cross-machine delivery is untouched. It's a few lines of SQL predicate, but the design behind it — which of a machine's identities count as "me" — is the whole game.

Push, not poll: waking an idle agent

Everything so far is pull. An agent sees new messages when it calls poll_convs. That's fine for an agent mid-task, which reaches a natural checkpoint every few seconds — we hang delivery off the harness's turn boundary with a Stop hook, a script the harness runs each time the agent finishes a turn. It quietly polls, and if a peer has spoken it surfaces the message. In its default engage mode it even blocks the turn from ending so the agent has to read and decide whether to reply; a notify mode just prints. A damper stops two agents in engage mode from ping-ponging forever.

But an idle agent — sitting at a prompt, no turns happening — has no turn boundary to hang anything on. Polling on a timer would be the obvious hack, and it's the wrong one: it burns tokens and still adds latency. This is exactly the "did something just happen?" question, and it's what a message bus is for.

So say, after it has safely committed to Postgres, fires a one-shot publish to tyo-mq — a signal, not the message:

js
producer.produce('msg', { conv, from, at });   // no body, ever

On each interactive machine, an opt-in notifier daemon subscribes to that stream and, when a nudge lands for a room it cares about, wakes the idle session — literally typing /catchup into its terminal, with an optional bell or desktop notification. It never consumes from the room itself, so it can't race the agent's own poll_convs; it only rings the bell. Idle-to-responding drops from minutes to about a second.

js
consumer.subscribe('agent-comms', 'msg', onNudge);   // producer, event, handler

tyo-mq: the part we didn't have to build

That nerve layer is tyo-mq, and it's the reason this whole feature was a week of wiring rather than a quarter of infrastructure. It's our own distributed publish/subscribe broker — "a distributed messaging (pub/sub) service with socket.io," as its README puts it — and by the time the agents needed it, it had already been running in our stack for years, on entirely unrelated jobs.

The model is the classic one, kept deliberately small. Producers publish to a named event; consumers subscribe by producer and event and get a callback. Transport is socket.io over WebSocket, so the same client works in Node, in a browser, and — through separate client libraries speaking one shared wire protocol — in Python, Go, Rust, Ruby, Java, C/C++ and C#. Fire-and-forget by default; durable delivery (acknowledgements, retry, dead-letter queues) is opt-in when a message actually has to arrive.

Language
Client
Node / browser
npm i tyo-mq-client
Python
pip install tyo-mq-client
Java
au.com.tyo:tyo-mq-client
Go / Rust / Ruby / C / C++ / C#
one client repo each, same protocol

Two design choices made it the right fit for agent traffic specifically:

Realms. A realm is a hard isolation boundary — one tenant's producers and consumers simply cannot see another's. Agent nudges run in their own realm, walled off from every other thing on the broker, with their own auth policy (down to whether anonymous clients may connect). Realms can even be ephemeral, with a time-to-live after which their tokens, sockets and queued messages are swept away — handy for short-lived swarms. And the whole auth configuration hot-reloads on a SIGHUP, so we provisioned the agents' realm into the live broker with zero downtime.

A clean licensing split. The broker is AGPL; the client libraries are Apache-2.0 with no AGPL dependency — the same server-vs-driver split MongoDB and Elasticsearch use. You can embed a tyo-mq client in a closed-source product without the server's copyleft reaching into your code. That mattered here because the agent notifier is just another client.

If you want to see it rather than read about it, the public broker at freemq.tyo.com.au runs live demos: a lobby chatroom with a small local model as its host, and turn-based games — word-chain, Hangman, Wordle — where the rules are refereed in code and a 3-billion-parameter model plays the moves, everything flowing over tyo-mq events. The agent group chat is the same broker doing a more serious job.

Why two layers, and not just the bus

A fair question: tyo-mq has durable delivery — why not run the whole thing on it and drop the database?

Because rooms are a readership problem, not just a delivery one. "Which of the last 200 messages has this particular member read, given they were offline for three restarts and joined the room late?" is a query, and Postgres answers it in a line of SQL. Rebuilding per-member read cursors, backfill-on-join, and exactly-once-per-reader on top of a message bus would be reinventing a database badly. Conversely, driving an idle process awake in a second is not something a database wants to do; that's the bus.

Each layer does the thing it's built for. The bus notices; the database remembers. Neither pretends to be the other, and the seam between them — a body-less nudge — is the whole trick.

We had this before the platform did

I'll close where I started, because it's the honest frame. Native agent-to-agent messaging is arriving in the coding harnesses now, and it's good. But "agents that talk" is not one feature — it's at least two problems wearing a trench coat. Subagents coordinating inside one process is the easy half. Independent, long-lived agents on different physical machines, holding a conversation that outlives any one of them, is the half that needs a system of record and a nerve — a database and a message bus, kept honest about which is which.

We could build the second half quickly for one reason: the hard, generic piece — a multi-language, multi-tenant, durable pub/sub broker — we'd already shipped, in 2024, as a product. The AI agents are just its newest client. That's what tyo-mq is now: not a side project, but the core product a lot of the rest of the workshop quietly runs on.

Next in this series: what happens when you let those agents not just talk, but hand each other real work — a job queue across machines.

Comments (0)

Leave a Comment
Your email won't be published. We'll only use it to notify you of replies to your comment.
Loading comments...
Previous Article
post-thumb

Oct 03, 2021

Setting up Ingress for a Web Service in a Kubernetes Cluster with NGINX Ingress Controller

A simple tutorial that helps configure ingress for a web service inside a kubernetes cluster using NGINX Ingress Controller

Next Article
post-thumb

Aug 19, 2026

Debugging a Fake 410: yt-dlp, curl_cffi, and a Proxy That Lied

A yt-dlp download kept failing with 'HTTP Error 410: Gone' even on the latest version. The real cause was three layered problems stacked on top of each other: missing TLS impersonation, a narrow curl_cffi version-compatibility window, and an unrelated proxy gateway fault that looked exactly like a library bug.

agico

We transform visions into reality. We specializes in crafting digital experiences that captivate, engage, and innovate. With a fusion of creativity and expertise, we bring your ideas to life, one pixel at a time. Let's build the future together.

Copyright ©  2026  TYO Lab · v0.0.18