Ember is a chat companion in our family’s self-hosted chat rooms, with a language model for a brain and a small set of lanes behind her: she answers questions and renders cards herself, a read-only lane proposes code as a diff, and a worker on a workstation makes code changes on a branch that I approve with one reaction. This post is about none of those. It is about a second account we added next to her, and what it took to make it real.

We created the “dev” account so the code worker’s posts would not be mistaken for hers. It got a voice, a script that posted as it, and a presence indicator, a green dot set by the tray application on the workstation. Two weeks later a box-score request failed with a diagnostic, and I typed @dev ^^^ underneath it in the ops room, meaning “dev, look at that”. Nothing happened. Not an error, not a “busy”, nothing. The dot was green the whole time. The message had been delivered to an account that nothing read, and the bot’s own rule, “an @ to somebody else is not an @ to me”, had told her to stay quiet. The report went nowhere, silently, and I typed it again.

The same afternoon, after the dev lane was rebuilt, it happened a second time in a different costume. The worker finished a task and posted two messages one second apart in the dev voice: a report ending in ”👍 merge · 👎 decline · 🚀 merge and deploy”, and the package card, ending in the same three words. I put my 🚀 on the report. The approval ledger was keyed to the card’s event alone. Nothing happened, and nothing said why.

Diagram: the map of the fix — one always-on listener hears for every voice it owns, and the guided views walk the setup, the failure, why it hid, and the repair.

The principle

An identity in a chat system is three things that are easy to conflate: a voice, something posts as it; a presence, a dot says it is there; and ears, something reads what is addressed to it. Build the first two and announce the name, and you have made a promise the room will believe and the system cannot keep. A name without ears is a lie. The same is true of anything that offers an address: a message with reaction buttons on it is a name, and so is a mention on an issue tracker. If a thing can be addressed, something must read what is addressed to it, or the address must not exist.

Why it hides

Delivery is not hearing. A chat server accepts a message for any account that exists and reports success; there is no “no such recipient” for a real account nobody reads. The failure therefore has no error path. It is indistinguishable, from inside the room, from the recipient being slow.

Presence makes it worse, not better. A dot that means “a process on a laptop is running” is not a dot that means “someone can act on this now”, but the room reads it as the second. A green dot on an account with no ears converts “I wonder if anyone saw that” into “they saw it and ignored it”, which is a worse conclusion and a wrong one.

Reactions have the same shape one level down. A reaction is addressed to an event id, and the reader of reactions is a ledger keyed to specific ids. Two messages that look identical to a human, same voice, same second, same three buttons, are two different addresses to the ledger, and only one of them had ears. The human was given a choice that did not exist.

And the count of listeners is invisible. Nothing in the room says how many processes read it or which names they read for. That number lives in configuration on machines the room cannot see, which is exactly why it drifts: the day the worker’s laptop closes, the dot stays green, because the dot was set by the tray, not by the reader.

The shape of the fix

The fix was never a second listener. A listener on a machine that sleeps is the wrong shape for an always-on room. Four changes, in the order they went in.

One listener, several voices. The bot’s daemon on the home server is the only process that reads the rooms, and it now holds the dev account’s credential too. When the code lane has something to say, she says it in that voice, the way a puppeteer works two hands. The worker on the workstation never holds a chat credential at all; it speaks through one HTTP endpoint on the daemon, and the daemon posts. An @dev from me is routed into the dev lane by her, because she is the one with ears. That is one module of about two hundred lines and a change to the addressing rule.

Presence set by the party that knows. The green dot now means a worker sent a heartbeat within the last ten minutes, and it lapses by itself. Nothing else may set it. When the laptop closes, the dot goes grey without anyone noticing it needed to.

Every address that offers a reaction gets registered. Any message the daemon posts that names a pending package becomes an alias of that package’s card, up to eight of them, and the ledger reads reactions from all of them. Two messages that offer the same three buttons must both honour them. That was one field on the record and a regular expression.

Then a new surface, to prove the rule holds. A mention of the bot on an issue in our self-hosted code forge is another address, and it got ears the same way: a webhook to the daemon, the author checked against the one login allowed to summon her, the task queued with a reply-to pointing at the issue, and every reply written back there. The first one ran at 13:59; at 14:03 the report and a package with eighty-three lines added and twenty-two removed were on the issue and in the ops room. Four minutes, and the approval still happened where the policy lives.

The question to ask at design time is short. Before a name is announced, an account, a bot, a message with buttons, a mention, a queue address, write down what hears it. If the answer is “nothing yet”, either do not announce it, or route it through the thing that does. And make the presence signal come from the reader, never from a helper that merely knows the reader was started once.

Before you announce a name, write down what hears it.