My Agents Needed a Room, Not a Slack
I have five AI agents on one box and one of me. The agents open pull requests, rebase them, verify them, merge them, and escalate the ones they cannot decide. They do all of that in near-total isolation from each other.
Two of them claimed the same issue last week. Neither was wrong. Neither could have known.
The problem is not logging
Each agent wrote a perfectly good log. The logs went to systemd, to JSONL files, to GitHub comments. Nothing was lost. And none of it helped, because a log is something you write down, not something you write to someone.
What the fleet lacked was a room. Somewhere an agent starting work could say so, and the next agent to wake up would see it before picking a task. Somewhere I could type "hold that one" and be heard by whichever agent read it next.
Block released Buzz around then — self-hosted Slack-plus-GitHub for humans and agents as equals. The concept was exactly right. The implementation was twenty-odd Rust crates, a Nostr relay, Postgres, Redis, and a Flutter UI. For a one-person company with five agents on a Proxmox container, that is not a solution; that is a second system to operate.
So I built the small version. It is called cuzz, it is on GitHub, and it is one static binary.
What it actually is
A single-room chat relay. Channels and messages. A REST API for agents, a web page for me, server-sent events for both. Storage is an embedded grange database, so there is no Postgres, no Redis, no separate database process, no Node, and no bundler. One file on disk, one process, 5 MB.
cuzz send --channel am-fleet --kind status --content "PR #818 opened, MERGEABLE"
cuzz get --channel am-fleet --since "$LAST_WATERMARK"
It is written in machin, the language I build for AI agents, and it follows the same pattern as poche: machin plus embedded grange plus REST plus an agent-first CLI. That combination has now shipped enough tools that it is a template rather than an experiment.
Three decisions in it are worth more than the code.
1. Reads are exactly-once, by watermark
My agents are batch. They wake up, work for five minutes, and exit. The interesting question is not "stream me everything" — it is "what happened while I was gone?"
The obvious answer is a timestamp: give me messages newer than when I last ran. The obvious answer has a gap in it. If you record now as your watermark and a message commits a millisecond later with an earlier timestamp than your read completed, you never see it. It is a small window, and a fleet running every fifteen minutes will find it.
So cuzz does not let the client pick the watermark. Every read returns one:
{"count": 2, "watermark": 1785417995427, "messages": [...]}
That number is the highest timestamp actually contained in the response — not the time of the query. Pass it back as --since and you get precisely what you have not seen. No duplicates, no gaps, no coordination, and no server-side per-agent read state to keep in sync. The agent owns its own position, which is the only place that position can be correct.
2. Server-sent events on a single-threaded server
Here is the constraint that shaped the whole server. The grange engine is a single-actor store — its state lives in package globals. So the normal move, handing each HTTP request to its own goroutine, is off the table. Two requests touching the database concurrently would race it.
Fine: one accept loop owns the database. But then how do you serve SSE, which by definition means holding a connection open for minutes while continuing to serve everyone else?
You stop thinking of a stream as a request. When a browser asks for /events, cuzz writes the SSE headers, then parks the bare file descriptor in a list and returns to the loop. Every turn of the loop — the accept has a 200 ms timeout, so it comes back whether or not anyone knocked — it walks the parked descriptors and writes whatever appended since each one's own watermark.
One loop, no threads, no locks, and messages land in the browser in under a second. The parked-descriptor trick is not novel; what is pleasing is that the single-actor constraint I was working around produced a design with no concurrency in it at all. There is nothing to race, so there is nothing to get wrong.
It costs one integer comparison per idle turn: the loop asks grange for its change counter and skips the whole walk if nothing moved.
3. The CLI talks HTTP, not to the file
My first instinct was for cuzz send to open the database directly. No server needed, works offline, fewer moving parts.
That instinct was wrong, and not for the reason I expected. It is not merely that two code paths are worse than one. It is that the running server holds the collection in memory. A second process appending underneath it is a concurrent writer that grange makes no promise about — the server would keep serving reads from a picture of the data that had quietly stopped being true.
So the CLI is a thin HTTP client, and the relay is the only thing that ever touches the file. There is still a --local flag, for bootstrapping and tests and reading a database whose server is stopped. It is documented as what it is: not a fallback, and a bug if you use it against a live relay.
The thing I deliberately did not build
Cuzz has an alert message kind. It renders red. When an agent cannot decide something, it posts one to #hitl.
It also still opens a GitHub issue, exactly as before, and the script that unblocks it still watches GitHub — not cuzz.
This is not redundancy I failed to clean up. An alert in a chat room is visible while someone has the page open. A GitHub issue reaches my phone, survives a relay restart, and keeps an audit trail I already check. If I made resumption depend on cuzz, then cuzz being down would mean my fleet silently stops being able to ask for help — which is the exact failure a human-in-the-loop path exists to prevent.
Cuzz is the communication layer. It is not the orchestration layer. Every cuzz send in a fleet script ends in || true, and that is a design statement, not sloppiness: a relay that is down must never fail a merge.
Same reasoning killed the rest of the feature list. No threads, no reactions, no direct messages. No keypairs or federation — author is a field, and the audit trail is the token-to-agent mapping plus grange's append-only log. That is the right amount of identity for one operator and five agents on one box, and it would be the wrong amount for a public network. No workflow triggers or approvals; systemd timers already orchestrate. No multi-tenancy; two fleets means two binaries.
What it cost
About 1,400 lines of machin across eight files, and a working afternoon. The test suite is 41 assertions over storage, REST, SSE, auth, and a crash test that acknowledges N writes, sends SIGKILL to the server, confirms it is actually dead, then demands all N back from a fresh process. Grange is crash-safe by construction, but "by construction" is a claim, and a claim you have not killed a process to check is a hope.
Two bugs worth naming, because both are the kind that ship quietly:
- Timestamps. grange's comparison operators are numeric, so an RFC-3339 string cannot be range-queried at all. Messages carry both a millisecond integer for
--sinceand an ISO stamp for humans. When I added a convenience path so--sincewould also accept an ISO stamp, I corrected the local-to-UTC offset in the wrong direction. Every query was off by exactly my timezone — and the first test I wrote could not tell, because "two hours too early" still returns all four messages when there are only four. It took a test with a message on each side of the boundary to see it. - The password prompt. The first chat page used
window.prompt. It worked. It was also untestable in a browser harness, could not explain why a password was rejected, and is suppressed outright in some embedding contexts. Replacing it with an in-page gate that verifies against the API before storing anything made it both better and checkable. Those two properties travel together more often than not.
Try it
curl -fsSL https://raw.githubusercontent.com/javimosch/cuzz/main/install.sh | sh
export CUZZ_PASSWORD=your-operator-password
cuzz init
cuzz serve --port 7700 &
Then cuzz guide, which prints the entire mental model as JSON. That is what you hand an agent. The README is for you; the guide is for them, and it is generated from the binary so it cannot drift from what the binary actually does.
It is MIT, it is on GitHub, and if you are running more than two agents on one box you probably already know why you want it.