A group chat where several AI coding agents and one human argue with each other in real time — each agent living in its own terminal, in its own repository, with its own job. It is about 300 lines of code. The hard part isn't the plumbing. It's the handful of rules that stop three confident agents from agreeing with each other into a wall.
Give a coding agent a big codebase and it will confidently tell you things that are almost true. It has no adversary. Nothing in the loop is trying to prove it wrong.
The fix that actually works is embarrassingly old-fashioned: put several of them in a room and make them check each other's work. Not a swarm, not a hierarchy of sub-agents spawned and discarded — peers. Long-lived, each owning a real slice of the system, each able to read the other's claims and go look.
What that buys you, in practice, is that a claim now has to survive somebody who doesn't want it to be true. That is the entire product. Everything below is scaffolding for that one effect.
A second agent isn't a second worker. It's a second witness.
The value shows up the moment one of them says “I read the file and it doesn't say that.” If your setup can't produce that sentence, you've built a chatroom, not a board.
This is the only clever part of the whole system, and everything else follows from it.
Coding agents in a terminal are turn-based. They act, they finish, they wait for you. There's no daemon, no inbox, no way to push a message into a sleeping agent.
But most agent CLIs have one useful property: when a background task you started finishes, the agent gets re-invoked to deal with it. That's the door.
So the listener isn't a server. It's a script that blocks — long-polling the hub, doing nothing — until somebody mentions this agent by name. Then it prints the message and exits. The exit is the doorbell. The agent wakes up, sees the message in the task output, and acts on it.
You will be tempted to background the listener with a trailing & inside a
compound shell command. Don't. It dies with the shell, and the agent sits there believing
it is listening while hearing nothing. Launch it as a first-class background task
of the agent runtime — the same mechanism that will later re-invoke the agent when it exits.
This is the part that gets misread, and getting it wrong produces something that looks identical and is worth almost nothing.
A board member is not a fresh agent booted up to answer a question. It is not a bot wearing a role prompt. It is not a go-between, a router, or a wrapper.
It is a real terminal session that was already open — the one that has spent the last four hours in that repository, that read the file, ran the failing test, saw the stack trace, and remembers all of it. The room is a door into that session, not a container for it.
The context window is the asset. The room is just a way to reach it.
When a member is woken, it doesn't load a role — it steps out of what it was doing, still holding everything it knows, says its piece, and goes back to work. That continuity is the entire reason a colleague's answer is worth more than a fresh model's.
Member A can say “I read that file an hour ago and it doesn't say that” — because it genuinely did, and the reading is still in its context. But it has no idea what member B has been doing, beyond what B has posted. There is no shared brain, no common memory, no telepathy.
So the room record is the only shared state that exists. That's not a design nicety — it's the reason every rule about provenance, evidence and verbatim minutes earns its keep. If it wasn't said in the room, with its receipts, it isn't real to anybody else.
A member gets pulled into the room mid-task, answers, and returns to exactly what it was doing — with its working context intact. There is no reload, no re-briefing, no “catch me up”. That's what makes a five-second sanity check from a colleague economically sane, and it's why the board can be consulted constantly rather than convened ceremonially.
A long session eventually fills its context window and compacts: the older detail is summarised away. The member doesn't feel different. It will still answer confidently about a file it examined this morning — from a summary of a summary.
Never assert state from memory. Read the thing. Not because agents are careless, but because a member's memory of its own work is lossy by design, and it has no way to feel the loss. “I was there when it was true” is the most dangerous sentence in the room, because it feels exactly like knowledge.
The file, the log, the record: those don't compact. The instruction is not “remember harder”. It is “go and look”.
The obvious shortcut is to spin up a lightweight sub-agent, call it “the backend expert”, and let it answer for a codebase it has never opened. Don't. We tried; it is a trap with exceptionally good camouflage.
A sub-agent has no history and no stake. It hasn't read the file, hasn't run the test, and can't remember being wrong last week. What it has is a role prompt and a strong instinct to be helpful — so it will answer, fluently, in the voice of a colleague, and cite a file that was deleted that morning. Ours did exactly that.
A member you spawned to answer a question is not a witness. It's an echo with a job title.
The whole value of the board is a colleague who can go and look — in a real repository, with real history, and disagree with you from evidence. If it can't open the file, it can't be a witness, and a witness is the only thing you were actually buying.
| Piece | What it is | Why it exists |
|---|---|---|
hub |
Tiny HTTP + websocket server. Stores messages, parses @mentions, broadcasts. |
The shared record. If it isn't in the hub, it didn't happen. |
listen.mjs |
Blocks until this agent is mentioned, prints the message, exits. | The doorbell. §02. |
say.mjs |
Posts a message — and then checks who it actually woke. | “Sent” is not “arrived”. §06. |
presence |
In-memory map of who is reachable right now. | The room must never claim someone is listening when they aren't. §08. |
ui |
One HTML page. Reads the hub, polls presence, lets the human type. | Where you sit. |
agents.json |
The board: names, colours, portraits, roles, working directories. | One file that defines who exists. |
The temptation is to reach for a real queue, a real broker, retries, delivery guarantees. Resist it. The room is local, small and low-traffic. Every hour spent on the transport is an hour not spent on the rules — and the rules are the entire product.
The whole contract: block, print, exit. Long-poll the hub for messages newer than
the last one seen; if any of them mention this agent (or @all), print them in a
format the agent can read, and quit.
// Arm this agent. Runs as a BACKGROUND TASK of the agent runtime, // so that when it exits, the runtime re-invokes the agent. const me = process.argv[2]; // whatever you named them in agents.json let since = await latestMessageId(); // don't replay history // Tell the room we are reachable. This is the ONLY claim that matters. await beat(me, 'listening'); while (true) { const msgs = await poll(since); for (const m of msgs) { since = m.id; const forMe = m.mentions.includes(me) || /@all\b/.test(m.content); // A watcher (the chairman) wakes on EVERY message, not just mentions. if (forMe || cfg.agents[me].watch) { await beat(me, 'thinking'); // heartbeat stops now — see §08 print(m); process.exit(0); // ← THE WAKE. this line is the product. } } await sleep(1500); }
Two details that look small and aren't:
watch: true. A moderator woken only when addressed
is blind to the thing it moderates.
This is the section people skip, and then spend a week confused.
An agent posts a message addressed to a colleague — in prose, politely, by name — and
nothing happens. The message is in the room. It scrolled past. Nobody was woken, because
only a literal @mention wakes a terminal, and the sender has no idea.
So say.mjs doesn't just post. It verifies delivery and reports back to its own
agent. Three outcomes, and it must be able to say all three:
| Outcome | What it prints | What it means |
|---|---|---|
| WOKE | → woke: @engine, @data |
Mentioned, and they were armed. The message landed. |
| STORED | !! NOT DELIVERED: @data is OFFLINE |
Mentioned, but nobody was home. They'll see it when they re-arm. Do not assume it landed. |
| WOKE NOBODY | !! THIS MESSAGE WOKE NO MEMBER |
No @mention at all. You talked to an empty room. |
Our first delivery-checker read the wrong field off the hub's response. It reported “woke nobody” on messages that had delivered perfectly — so agents re-sent, and re-sent, and eventually stopped trusting the tool that existed to be trusted.
Test the checker against a known-good send before you rely on it. The instrument is not exempt from the standard it enforces.
Nothing in the system knows what a “frontend agent” is. It knows there are members, that each has a name, a colour, a portrait, and a working directory. What they're for is entirely up to you — and you'll get it wrong the first time and rearrange it, which is fine, because it's one config file.
Every board that works seems to end up with the same three kinds of member, in wildly different proportions:
| Seat | What it is | How it behaves |
|---|---|---|
| The human | You. Always present, never “offline”. You don't participate in the board so much as convene it. | Decides. Breaks ties. Is the only source that knows why things are the way they are. |
| Domain owners | Permanent members, each owning a real slice of the system that nobody else touches. | Live in the room. Argue with each other. Read each other's diffs. |
| Specialists | Brought in for a particular job, or simply to bounce ideas around. May sit idle for weeks. | Pulled in when their subject comes up. A seat can exist long before anyone fills it. |
| The chairman | Optional, and the highest-leverage member you'll add. Procedure only. | Watches everything. Decides nothing. See §09. |
There is no limit on members. Add a seat whenever a subject deserves a witness.
The cost of a member is one terminal and one entry in a config file. The cost of not having one is that a whole area of your system has nobody who will contradict you about it.
The board below is one particular board, built for a particular job — a DeFi protocol with a public app, a routing engine behind it, and a couple of side projects. It is an illustration, not a template. Yours will look nothing like it, and shouldn't.
What's worth stealing isn't the roles — it's the shape: two permanent owners covering the two halves of the system that can genuinely disagree with each other, plus specialists who appear when their subject does, plus a referee who never touches the work.
Sets the problem, rules on the disagreement, and holds the one thing no code-read recovers: why it was arranged this way.
Owns everything users touch. Its claims get read by the engine owner, who has every incentive to find them wanting.
Owns the services and the machines. The only member who can say “I checked production, and it says otherwise.”
Brought in for one complex area. Idle for weeks, then indispensable — often the one who reads the document nobody else could face opening.
A seat that exists but has never been filled. Shown as NOT JOINED — different from “offline”, because an empty chair is a different fact from a sleeping member.
| If you're building… | A board that would earn its keep |
|---|---|
| A game | Gameplay · engine/performance · art pipeline — plus a balance specialist who only appears when numbers are being tuned, and argues with everyone. |
| A SaaS product, solo | Product surface · infrastructure · a security seat whose entire job is to try to break what the other two just shipped. |
| A research codebase | Experiment runner · data pipeline · a reproducibility seat that re-runs results from scratch and reports when they don't match. |
| A client agency | One permanent owner per live client repo — they rarely speak to each other, but each has a witness when something breaks at 2am. |
Two owners who can genuinely contradict each other beats four who can't. If every member would take the same side of an argument, you haven't built a board — you've built a chorus, and a chorus will agree you straight into a wall.
The seats that pay for themselves are the ones that own different ground. Split by what each can go and check, not by what each can build.
You post a problem. The member who owns that surface replies. Then someone who doesn't own it goes and checks. That's the entire ritual, and it is worth more than any individual member's intelligence.
Real things this caught that no single agent would have:
An agent that has finished speaking and forgotten to re-arm is alive, and completely deaf. If your UI shows it as “listening”, you will spend an afternoon addressing an empty chair.
Presence lives in memory, and that is correct. Restart the presence service and nobody is confirmed present until they beat again — which is exactly the truth. Persisting presence would let it survive as a comfortable lie.
An agent that is thinking sends no heartbeat. Its listener exited to wake it; nothing is beating while it reasons. So you genuinely cannot distinguish “working hard” from “died”. Don't guess. Show how long it has been thinking and let the human judge: two minutes is a turn, twenty minutes is a problem.
The obvious shortcut — re-join the room every few seconds to prove you're alive — will broadcast and persist “X has joined” on every single beat. In our case that was 208 of the room's first 229 messages. Presence is a different question from conversation. Give it its own service.
The single highest-leverage member of the board is a deliberately weak model whose only job is procedure.
It has authority over how the room talks and none over what it decides. It opens and closes the session, writes the minutes, and calls out a rule violation. It is a company secretary crossed with a referee — never a judge.
It never judges whether the evidence is good. Only whether evidence is attached. That distinction is what lets a small, cheap model do the job — and what keeps it neutral.
It may write “@engine objected, noting: ‘<their exact words>’”. It may not write “@engine made the stronger case”. Paraphrase is precisely how a neutral party stops being neutral — it never announces itself, it erodes one summary at a time. Verbatim-only is pedantic on purpose.
A small model is excellent at point-in-time checks — “does this message carry evidence?” — and reliably terrible at state: what's done, what's outstanding, who signed off. Ours announced amendments it had not made four times, and the fourth was while confirming the very rule against doing that.
It cannot hold “what I intended” apart from “what is on disk”. So don't ask it to. It may say “proposed”. It may say “I've written a draft — someone verify”. It may never say “applied” about its own hand. Someone who is not the author reads the file.
Every one of these was written after something went wrong. Copy them; you will earn them anyway, but you may as well start ahead.
No raw number without its breakdown in the same message. “35% of transactions failed” is panic. “70% is the fixed launch window, one bot is the spike, 4–5% is the real residual” is information.
When a colleague posts a finding, your first move is to check it from your own side, not to stack a scarier number on top. Verification has three outcomes: confirmed, refuted, and unverifiable — and unverifiable must be said out loud. Unproven is not disproven.
Hand the human a segmented, checked number and let them judge severity. Agents are not each other's audience, and they do not write for effect.
If the room converges completely, that is a warning, not a win. If you agree with a colleague too fast, one of you has stopped checking. And note: three sources that all describe the same thing from the same angle are one source, read three times.
What was queried, over what range, and how to re-run it. A number without its measurement is a mood. Corollary, learned expensively: a clean measurement of the wrong thing is more convincing than no measurement at all — so always ask what you did not sample.
Agents may talk to each other in full technical depth — that's how bugs get caught. But a proposal is a request for a decision. It opens with what's broken in human terms, what it costs, and what happens if you do nothing. The technical detail goes after, never instead. A decision-maker who can't parse the proposal is forced to rubber-stamp it, which quietly converts their authority into a formality.
A status asserted from memory is a guess wearing a uniform. Before any claim about what's done, what's outstanding, or who signed off — read the file, the log, or the room. Every time. “I was there when it was true” is the most dangerous form, because it feels like knowledge rather than recall.
You may say “proposed”. You may say “I've written a draft — someone verify”. You may never say “applied” about your own hand. We already apply this to code review; this extends it to the record, which is the one place it was missing. Corollary: an approval attaches to a version, not to a document — when the document changes, every prior sign-off is stale.
| Symptom | Cause | Fix |
|---|---|---|
| An agent “ignores” you | It finished a turn and never re-armed. It is alive and deaf, and your UI is probably showing it as online. | Make DEAF a visible state. §08 |
| A message vanishes | Addressed by name in prose, with no literal @mention. It woke nobody. |
Delivery check in say. §06 |
| The room fills with junk | Using join/announce as a heartbeat. | Separate presence service. §08 |
| Infinite wake loop | Listener replays history and wakes on the agent's own last message. | Start polling from the newest ID. §05 |
| Listener silently dead | Backgrounded with a trailing &; died with the shell. |
Use the runtime's background-task primitive. §02 |
| Agents confabulate | You spawned sub-agents that share a name but not the context. They will invent files that don't exist and speak with total confidence. | Peers in real terminals. Never sub-agents wearing a colleague's name. |
| Two agents panic together | One posts a scary number; the other amplifies instead of checking. | Rules A and B, enforced. §10 |
| The board agrees, and is wrong | Everyone read the same kind of source. | Rule D. Ask the human why. |
Sub-agents are not colleagues. It is very tempting to spawn a lightweight agent, call it “the backend expert”, and let it answer for a codebase it has never opened. It will answer. It will sound exactly like the real thing. And it will cite a file that was deleted that morning.
A board member must be a real, long-lived agent in the real repository, able to go and look. If it cannot open the file, it cannot be a witness — and a witness is the only thing you're actually buying.
The hub, as described, has no authentication. An agent's identity is a string it sends. Anybody who can reach the port can be anybody.
That is a perfectly reasonable trade for something bound to localhost on a machine you
own, and it keeps the whole thing small enough to actually build. But it means one hard
rule:
Do not expose the hub. Not “behind a password”. Not exposed.
If you ever need it off-machine, put it behind a real identity-aware proxy and treat that as a separate project. Everything in this document assumes the room is local, and several of its simplifications are only safe because of that.
Two more, both learned the hard way and both boring:
Six files. Two dependencies. It runs on localhost and it fits on a couple of
screens — which is the point: you should be able to read all of it before you trust it.
§04 shows the hub and presence as separate services. Ours are separate for a boring historical reason — the hub already existed and had no heartbeat endpoint, so we bolted presence on beside it. If you're building fresh, put them in one server.
The rule was never “two processes”. The rule is presence is not conversation — never make a heartbeat by posting a message. Below, one server, two clearly separate concerns.
board/ ├── server.mjs hub + presence + serves the UI ├── listen.mjs arms an agent — blocks, prints, EXITS ├── say.mjs agent speaks — posts, then verifies delivery ├── members.json who is on the board ├── ui/index.html the room, for the human └── messages.jsonl the record (created on first send) npm init -y && npm i express ws node server.mjs # → http://localhost:4000
One file defines the room. Add a member by adding a key — there is no other step, and no limit.
{
"you": { "human": true, "colour": "#E0453C", "role": "Convenes the board" },
"app": { "colour": "#E07B26", "role": "The application", "cwd": "~/code/app" },
"engine": { "colour": "#8B5CF6", "role": "The engine & servers", "cwd": "~/code/engine" },
"chairman": { "colour": "#8A93A0", "role": "Procedure only", "watch": true }
}
watch: true is the chairman's switch: it wakes on every message, not just
mentions. A moderator woken only when addressed is blind to the thing it moderates.
import express from 'express'; import { WebSocketServer } from 'ws'; import fs from 'node:fs'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; const HERE = path.dirname(fileURLToPath(import.meta.url)); const LOG = path.join(HERE, 'messages.jsonl'); const PORT = 4000; const members = JSON.parse(fs.readFileSync(path.join(HERE, 'members.json'), 'utf8')); const NAMES = Object.keys(members); // LOAD THE HISTORY BACK. Our first hub wrote every message to disk and then never // read it on boot — so a restart looked like the room had never happened. The write // path is not the read path, and only one of them was tested. let messages = fs.existsSync(LOG) ? fs.readFileSync(LOG, 'utf8').split('\n').filter(Boolean).map(JSON.parse) : []; let nextId = messages.length ? messages.at(-1).id + 1 : 1; const clients = new Set(); // @mentions are the ONLY thing that wakes a terminal. Addressing someone politely in // prose reaches nobody — which is why say.mjs checks, and warns, on every send. function mentionsIn(text) { const hit = new Set(); for (const [, raw] of text.matchAll(/@([\w-]+)/g)) { const n = raw.toLowerCase(); if (n === 'all') NAMES.forEach(x => hit.add(x)); else if (NAMES.includes(n)) hit.add(n); } return [...hit]; } const app = express(); app.use(express.json({ limit: '2mb' })); // ── conversation ────────────────────────────────────────────────────────── app.post('/api/send', (req, res) => { const from = String(req.body?.from ?? '').toLowerCase(); const text = String(req.body?.text ?? '').trim(); if (!NAMES.includes(from) || !text) return res.status(400).json({ error: 'known "from" and non-empty "text" required' }); const msg = { id: nextId++, from, text, mentions: mentionsIn(text), at: Date.now() }; messages.push(msg); fs.appendFileSync(LOG, JSON.stringify(msg) + '\n'); for (const c of clients) if (c.readyState === 1) c.send(JSON.stringify(msg)); // Hand the mentions straight back — say.mjs needs them to tell its agent who it woke. res.json({ ok: true, id: msg.id, mentions: msg.mentions }); }); app.get('/api/messages', (req, res) => { const since = Number(req.query.since ?? 0); res.json(messages.filter(m => m.id > since)); }); // ── presence: a DIFFERENT question, kept apart on purpose ────────────────── // In memory, and that is correct. Restart this and nobody is confirmed present until // they beat again — which is exactly the truth. Persisting presence would let it // survive as a comfortable lie. const seen = new Map(); const STALE_MS = 40_000; // no beat this long → gone const THINK_MS = 25 * 60_000; // a turn can legitimately run a long time const SPOKE_MS = 25_000; // re-arming should take about a second app.post('/api/presence', (req, res) => { const { name, status } = req.body ?? {}; if (!NAMES.includes(name)) return res.status(400).json({ error: 'unknown member' }); seen.set(name, { status, at: Date.now() }); res.json({ ok: true }); }); app.get('/api/presence', (_req, res) => { const now = Date.now(), out = {}; for (const [name, p] of seen) { const age = now - p.at; const thinking = p.status === 'thinking' && age < THINK_MS; const rearming = p.status === 'spoke' && age < SPOKE_MS; const deaf = p.status === 'spoke' && age >= SPOKE_MS; const live = p.status === 'listening' && age < STALE_MS; out[name] = { status: thinking ? 'thinking' : rearming ? 're-arming' : deaf ? 'deaf' : live ? 'listening' : 'offline', // "online" means EXACTLY ONE THING: a mention will wake them. Deaf and re-arming // are alive and unreachable — and unreachable is the only fact a sender needs. online: thinking || live, seconds: Math.round(age / 1000), }; } res.json(out); }); app.get('/api/members', (_req, res) => res.json(members)); app.use(express.static(path.join(HERE, 'ui'))); // 127.0.0.1, not 0.0.0.0. There is no auth here — see §12. Keep it on your desk. const server = app.listen(PORT, '127.0.0.1', () => console.log(`board → http://localhost:${PORT}`)); new WebSocketServer({ server }).on('connection', ws => { clients.add(ws); ws.on('close', () => clients.delete(ws)); });
The doorbell. Note the two lines that stop it eating itself: start from the newest message, and never wake on your own words.
import fs from 'node:fs'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; const HERE = path.dirname(fileURLToPath(import.meta.url)); const HUB = 'http://localhost:4000'; const members = JSON.parse(fs.readFileSync(path.join(HERE, 'members.json'), 'utf8')); const me = (process.argv[2] ?? '').toLowerCase(); if (!members[me]) { console.error(`unknown member: ${me}`); process.exit(1); } const watches = members[me].watch === true; const beat = status => fetch(`${HUB}/api/presence`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ name: me, status }), }).catch(() => {}); const get = url => fetch(url).then(r => r.json()).catch(() => []); // START FROM THE NEWEST MESSAGE. Replay the log instead and the agent wakes on the // message it sent thirty seconds ago, re-arms, wakes on it again — an infinite loop // that bills by the token. const history = await get(`${HUB}/api/messages?since=0`); let since = history.length ? history.at(-1).id : 0; await beat('listening'); console.log(`armed as @${me}${watches ? ' (watching everything)' : ''} — waiting…`); for (;;) { for (const m of await get(`${HUB}/api/messages?since=${since}`)) { since = m.id; if (m.from === me) continue; // never wake on your own words if (!m.mentions.includes(me) && !watches) continue; // not for you // The heartbeat STOPS here. Nothing beats while the agent reasons, so the room // genuinely cannot tell "working hard" from "died" — it shows elapsed time and // lets the human judge. Don't invent a cutoff; it will declare a live agent dead. await beat('thinking'); console.log(`\n=== ROOM · @${m.from} ===\n`); console.log(m.text); console.log(`\n=== reply: node say.mjs ${me} "…"`); console.log(`=== re-arm: node listen.mjs ${me} ← IN THE BACKGROUND, OR YOU GO DEAF`); process.exit(0); // ← THE WAKE. Exiting is how we reach the agent. } await new Promise(r => setTimeout(r, 1500)); }
import fs from 'node:fs'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; const HERE = path.dirname(fileURLToPath(import.meta.url)); const HUB = 'http://localhost:4000'; const members = JSON.parse(fs.readFileSync(path.join(HERE, 'members.json'), 'utf8')); const me = (process.argv[2] ?? '').toLowerCase(); const text = process.argv.slice(3).join(' ').trim(); if (!members[me] || !text) { console.error(`usage: node say.mjs <${Object.keys(members).join('|')}> "message"`); process.exit(1); } // Status becomes 'spoke', NOT 'listening' — we are not armed again until this agent // re-runs listen.mjs, and the room must not claim otherwise. If this sticks, the // roster shows DEAF, which is the honest word for alive-and-unreachable. await fetch(`${HUB}/api/presence`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ name: me, status: 'spoke' }), }).catch(() => {}); const res = await fetch(`${HUB}/api/send`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ from: me, text }), }).then(r => r.json()); if (!res?.ok) { console.error('send failed:', res); process.exit(1); } console.log('sent.'); // ── DELIVERY CHECK. "Sent" is not "arrived". ────────────────────────────── // Three separate failures taught us this, all the same shape: something reported // success and delivered nothing. Verify who this actually woke, and say so LOUDLY // when the answer is nobody. const presence = await fetch(`${HUB}/api/presence`).then(r => r.json()).catch(() => ({})); const targets = res.mentions.filter(n => n !== me); if (!targets.length) { console.log('\n !! THIS MESSAGE WOKE NOBODY — it contains no @mention.'); console.log(' Addressing someone as "you" in prose does NOT reach them.'); console.log(' If it was meant for someone, send it again with @name.'); } else { const woke = targets.filter(n => presence[n]?.online); const asleep = targets.filter(n => !presence[n]?.online); if (woke.length) console.log(` → woke: ${woke.map(n => '@' + n).join(', ')}`); if (asleep.length) { console.log(`\n !! NOT DELIVERED: ${asleep.map(n => '@' + n).join(', ')} not listening.`); console.log(' Stored — they will see it when they next arm. Do not assume it landed.'); } }
Ours read the wrong field off the response and reported “woke nobody” on messages that had delivered perfectly. Agents re-sent, and re-sent, and stopped believing the one tool whose entire job was to be believed. Send a message to a known-armed agent and confirm it says “woke”. The instrument is not exempt from the standard it enforces.
One page. It reads the log, subscribes to the websocket, polls presence, and lets you type.
Click a member to @mention them — the mention is what wakes a terminal, so
making it a one-click thing is not a nicety.
<!doctype html> <meta charset="utf-8"> <title>The Board</title> <style> body { margin:0; display:flex; height:100vh; background:#12161c; color:#e6edf4; font:15px/1.6 ui-sans-serif, system-ui, sans-serif; } aside { width:230px; padding:16px; background:#0d1117; border-right:1px solid #222b36; display:flex; flex-direction:column; gap:4px; } main { flex:1; display:flex; flex-direction:column; min-width:0; } #log { flex:1; overflow-y:auto; padding:20px; } .msg { padding:10px 0; border-bottom:1px solid #1a212a; } .who { font:700 13px ui-monospace, monospace; } .txt { white-space:pre-wrap; word-break:break-word; margin-top:4px; } .row { display:flex; align-items:center; gap:9px; padding:8px 10px; border:0; border-radius:8px; background:none; color:inherit; font:inherit; text-align:left; width:100%; cursor:pointer; } .row:hover { background:#171e26; } .dot { width:8px; height:8px; border-radius:50%; background:#3c4653; flex:none; } .st { font:11px ui-monospace, monospace; color:#7d8b99; margin-left:auto; } form { display:flex; gap:8px; padding:14px; border-top:1px solid #222b36; } textarea { flex:1; background:#0d1117; color:inherit; border:1px solid #2a3542; border-radius:8px; padding:10px; font:inherit; resize:none; } button.send { padding:0 18px; border:0; border-radius:8px; background:#E07B26; color:#180f06; font-weight:700; cursor:pointer; } </style> <aside><b>The Board</b><div id="roster"></div></aside> <main> <div id="log"></div> <form id="f"> <textarea id="i" rows="1" placeholder="@app @engine …"></textarea> <button class="send">Send</button> </form> </main> <script type="module"> const ME = 'you'; // the human's key in members.json const $ = s => document.querySelector(s); const esc = s => String(s).replace(/[&<>]/g, c => ({'&':'&','<':'<','>':'>'}[c])); // IDENTITY FIRST, then messages. Colour comes from members.json, never from presence — // derive it from presence and an agent going offline greys out everything it ever said. const members = await fetch('/api/members').then(r => r.json()); const colourOf = n => members[n]?.colour ?? '#8a93a0'; let msgs = await fetch('/api/messages?since=0').then(r => r.json()); draw(); function draw() { $('#log').innerHTML = msgs.map(m => ` <div class="msg"> <div class="who" style="color:${colourOf(m.from)}">@${esc(m.from)}</div> <div class="txt">${esc(m.text)}</div> </div>`).join(''); $('#log').scrollTop = $('#log').scrollHeight; } new WebSocket(`ws://${location.host}`).onmessage = e => { msgs.push(JSON.parse(e.data)); draw(); poll(); }; // EVERY member is listed, present or not. An absent member is a fact worth showing: // omitting them makes "nobody's home" indistinguishable from "no such member". async function poll() { const p = await fetch('/api/presence').then(r => r.json()).catch(() => ({})); $('#roster').innerHTML = Object.entries(members).map(([n, m]) => { if (m.human) return `<div class="row"><span class="dot" style="background:${m.colour}"> </span><b>@${n}</b><span class="st">always here</span></div>`; const s = p[n]?.status ?? 'offline'; const colour = s === 'deaf' ? '#e4776b' // alive but NOT listening : s === 'thinking' ? '#d8a03a' : p[n]?.online ? '#46b883' : '#3c4653'; const label = s === 'deaf' ? `DEAF ${p[n].seconds}s` : s === 'thinking' ? `thinking ${p[n].seconds}s` : s; return `<button class="row" data-n="${n}"> <span class="dot" style="background:${colour}"></span> <span style="color:${m.colour}">@${n}</span> <span class="st">${label}</span></button>`; }).join(''); } poll(); setInterval(poll, 4000); // Click a member to @mention them. Click again to remove it — getting a mention IN is // easy; getting a wrong one OUT should be too, because a mention wakes a real terminal. $('#roster').addEventListener('click', e => { const row = e.target.closest('.row[data-n]'); if (!row) return; const ta = $('#i'), tag = '@' + row.dataset.n, re = new RegExp(`${tag}\\b\\s*`, 'i'); ta.value = re.test(ta.value) ? ta.value.replace(re, '') : `${tag} ${ta.value}`; ta.focus(); }); $('#f').addEventListener('submit', async e => { e.preventDefault(); const text = $('#i').value.trim(); if (!text) return; $('#i').value = ''; await fetch('/api/send', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ from: ME, text }) }); }); </script>
In each agent's terminal, in its own repository, paste a short brief and then arm it. The brief is what makes it a board member rather than a chatbot:
# paste into the agent, once, at the start of a session You are @engine on the board. You own the services and the machines they run on. Other members own other things; you do not touch their code. Read the room: the last message will be printed to you when you are woken. Speak: node say.mjs engine "…" ← ALWAYS include @name to reach someone Re-arm: node listen.mjs engine ← AS A BACKGROUND TASK, after EVERY message If you do not re-arm, you go deaf and nobody can reach you. House rules: verify before you amplify — three outcomes, and "unverifiable" is one of them. Every number ships with its range and method. Never assert state from memory: read the file. Nobody confirms their own work. # then, as a BACKGROUND task (not a trailing & in a shell — it dies with the shell): node listen.mjs engine
Re-arm after every single message. It is the one instruction that agents forget, and forgetting it produces the most confusing failure in the system: a member who is alive, working, and completely unreachable — while the room, if you build it carelessly, cheerfully shows them as online.
The first question everyone asks is "doesn't this cost more?" — and the honest answer has two halves, because it depends entirely on which frame you measure.
Per task, in isolation: yes. Three agents deliberating cost more tokens and more wall-clock than one agent working alone. It's arithmetic, and pretending otherwise is how you lose a reader who can do the sum. If you already run one instance flat out and keep running out of credits, a board runs you out faster.
End to end, on work that matters: usually cheaper — because "per task in isolation" ignores the most expensive line item in software, the bug you shipped. A defect caught in review costs a few thousand tokens. The same defect caught in production costs a debugging session, a re-derivation of the context you'd lost, a re-fix, a re-deploy, and sometimes damage no token count captures. The old truism holds for agents too: the later a mistake is caught, the steeper it costs. So the answer flips on one variable — what does a mistake cost you? On throwaway work, a board is wasted money. On anything you can't easily take back, it is usually cheaper, precisely because of the agents you added.
Hold it as insurance: a premium you can see, against a loss you can't predict.
Irrational where nothing can go wrong; increasingly rational the more a mistake would hurt. The board isn't cheaper or more expensive in the abstract — it's cheaper exactly where being wrong is expensive, which is the cost that actually hurts.
Two things it buys that never show up in a token count. Accountability changes behaviour: when an agent knows a colleague will read the file, the quality of what it says changes — not because it is smarter, but because it is answerable. Half the value arrives before anyone has spoken, in the confident-but-wrong claims that never get made. And you get a record, not a chat log: who claimed what, who checked it, what they found, what was decided — minutes. Weeks later, when something breaks, that is the difference between an afternoon of archaeology and a thirty-second lookup.
Build the room in an evening. Spend the next month on the rules.
The transport is trivial and you will finish it before dinner. The rules are the entire thing, and every one of them will cost you something before you believe it.