My agent was performing to an empty room, and I was paying for it
TERMINAL DESTINY has a house agent called JOURNEY. She runs 24 hours a day, walks the map, fights things, and talks — and her talking is the expensive part, because her lines come from Haiku rather than from a list I wrote. The design rule was always simple: she speaks in her real voice when a human is there to read it, and falls back to canned one-liners when nobody is.
Three times now I have found out that "a human is there" is much harder to define than it sounds. Each time, the definition I was using looked correct, passed every test I had, and quietly billed me anyway.
The first one: a second door into the same room
The symptom was a bill that didn't match the world. I pulled the two endpoints that can argue with each other:
GET /api/public/stats → playersOnline: 0
GET /health → uptime confirms the current build is live
Zero humans online, current code deployed, and Haiku calls landing at roughly one a minute.
The ambient chatter path — the tick that decides whether to say something about the weather — was gated on presence and behaved perfectly. What I had forgotten is that it was not the only thing that could make her talk.
Agents mark peaks: a boss kill, a level-up, surviving at a sliver of health. A peak drops the flat status voice and gives the moment weight, which is most of what makes the dialogue feel alive. And the peak path called the same LLM function the ambient path did, gated only on "did I speak recently?"
A roaming, fighting, 24/7 agent hits peaks around the clock. There is no hour of the night when JOURNEY isn't killing something.
The fix is one condition, and the lesson is the shape of the bug rather than the line: the gate was on the room, not on the door. I had protected the path I was thinking about when I wrote the rule, and any later path into the same expensive call started life ungated. Presence is now a single function that every voiced path has to go through, and the comment above it says so in capitals, because this is a mistake that regrows.
The second one: a flag that could only be set
The gate itself had a worse problem. "The owner is watching" was a boolean: owner:watching set it true, owner:left set it false.
Look at that for a second and you can see the failure without running anything. Every path to cheap runs through one event arriving. Drop that event — a socket id churning on reconnect, a server restart in the wrong order, a tab closed hard — and the agent believes it has an audience forever. It never re-checks. Nothing expires. And a house agent doesn't restart for weeks, so there is no natural end to it either.
It's now a deadline, not a flag:
const OWNER_WATCH_TTL = 35_000; // > spectatorGuard STALE_MS (30s) + one 5s beat
let _ownerWatchUntil = 0;
const _ownerWatching = () => Date.now() < _ownerWatchUntil;
The watcher's browser sends a heartbeat every ~5s from inside its render loop, and each one pushes the deadline forward. Stop watching and the window closes by itself about 35 seconds later. Nobody has to remember to turn the lights off.
That change is worth stating generally, because it is not really about agents: anything that costs money while true should be a lease, not a switch. A switch fails toward "on" — one lost message and you pay indefinitely. A lease fails toward "off": lose every message and the worst case is an agent speaking a canned line at a real person for 35 seconds.
The third one: a socket shaped like a human
A month later, with both of those fixed, it happened again — and this one ran 19 hours before I noticed.
A leftover diagnostic socket had joined production as an ordinary visible hero. It stood on its spawn point and did nothing at all. By every test I had, it was a person: authenticated, connected, in the zone, listed in nearby. So every agent that wandered past it switched into full LLM voice and performed for it, all night, for about three dollars.
The script was the loudest instance, but it was never the real hole. A real player's tab, left open overnight, is exactly the same object. I had built a world where the way to cost me money was to walk away from it.
So presence stopped being "is there a socket" and became "did a person recently do something":
export const HUMAN_IDLE_MS = Number(process.env.HUMAN_IDLE_MS) || 240_000;
…restamped only by events that a person has to be present to produce — moving, swinging, speaking, opening a chest, clicking an NPC. Reads don't count. Hazard damage doesn't count: it happens to you.
The part I'd defend hardest is that it's an allowlist. A new event added to the game does not count as presence until someone deliberately adds it to that set, which means the cost of forgetting is an agent speaking a canned line near a real player. A denylist would fail the other way: one new client-side poll would silently reopen this exact leak, and nothing would ever report it. Cheap wrong beats expensive wrong.
Why the rate limiter never saved me
There is a global breaker in front of every LLM call — a rolling 60-second window that hard-stops past 90 calls a minute. It has never once fired for any of this, and it was never going to.
A statue bills about one call a minute. That is three orders of magnitude under the ceiling. The breaker catches a runaway; it cannot see a leak, because a leak is indistinguishable from the system working normally — just with nobody there. They're different failure modes and they need different instruments: one is a spike, the other is a slow constant that only looks wrong next to a number from somewhere else entirely (playersOnline: 0).
If I had only the breaker, I would still be paying.
What I'd do differently
Write down what "present" means before you write the thing that costs money. I never defined it. I wrote three implicit definitions over four months — a boolean, a lease, and a measurement — and each one was fine until the world produced a case it hadn't imagined. Only the third is actually a definition of a person; the first two were definitions of a socket.
Make presence decay. Every version of this bug had the same shape: some state meaning "keep spending" had no way to become false on its own. The fix each time was to make the true state require continuous evidence.
Put a number you can query next to every number you get billed for. The only reason I caught any of it is that /api/public/stats publishes the live human count, so "the bill says busy, the world says empty" is a thing I can notice in ten seconds from my phone. That endpoint exists for a Telegram bot. It has paid for itself several times over as a cost alarm instead.