Ulric
Book a call

Eugene, Oregon · one person, whole builds

Insights

A status file per bot: how a small office of agents reports in

A status file per bot: how a small office of agents reports in

Multi-agent monitoring sounds like something that needs a message bus. Mine is a folder of small JSON files. Every bot in my studio, the Claude Code sessions and the Grok desks led by one I call VP, rewrites a single file saying what it is doing and when it last said anything. A page reads that folder every ten seconds and draws an office where each bot has a desk, a lamp and a name plate. When a bot goes quiet, its desk goes dark and the whole floor loses daylight.

What does multi-agent monitoring actually need?

Two answers per agent, refreshed often enough to be worth looking at: is this thing alive, and what is it on. Everything else is nice to have. The OpenTelemetry project puts the general case bluntly in its May 14, 2026 post on GenAI observability, describing the chain of model calls, tool invocations and token exchanges that happens behind any LLM request: "without observability, you are guessing."

That is true, and a much bigger problem than mine. My desks are not sub-agents inside one request. They are separate long-running workers on two different runtimes that never call each other. They share a private git repo and nothing else. So the smallest honest thing that answers both questions is a file per worker, written by that worker, read by a page.

A six-step pipeline. On the write side: a desk finishes a slice, rewrites monitor/status/slug.json with id, name, room, role, state, doing, updated, block, next and week, then commits and pushes, which is the deploy. On the read side: the browser polls api.php every ten seconds with an ETag, the server classifies each file's age against its own clock, and the desk changes. A panel underneath lists the five things the classifier can say: unknown for a missing file, fresh inside four hours, aging inside twenty-four, stale beyond that or with no timestamp, and cold beyond forty-eight hours.

What goes in one bot's status file?

A handful of fields, with a schema in the repo the bots are pointed at. Here is a real one, with the identifier, a Drive link and one older week entry trimmed:

{
  "name": "Priya · Product",
  "room": "Ulric",
  "role": "Product",
  "state": "idle",
  "doing": "Ulric products. Not client marketing.",
  "updated": "2026-08-30T05:28:00-07:00",
  "block": null,
  "next": "Tempo unresolved.",
  "week": [
    { "at": "2026-08-27T19:15:00-07:00",
      "did": "Expert training: domain brief + skills lock" }
  ]
}

state is one of four words: idle, working, blocked, parked. doing is one line. block is why it is stuck, or null. next is the next slice. week is what it actually finished over the last seven days, and it stays an empty array when nothing real happened. updated is an ISO 8601 stamp with an offset, and it is the only field the agent status dashboard treats as a heartbeat.

The rule that makes this work is boring and absolute: a desk edits only its own file, and it commits that file with the work it describes. Nothing aggregates. Nothing writes on another bot's behalf. A status commit that arrives without any code beside it is a bot telling me it is idle, which is also information.

How does the dashboard decide what counts as stale?

By age, measured on the server's clock, never the browser's. Older than twenty-four hours is stale. No timestamp at all is also stale. A missing file is a third thing: not stale but unknown, and that desk is drawn empty rather than guessed at.

function is_stale(string $updated): bool {
    if (trim($updated) === '') return true;
    $t = strtotime($updated);
    return $t === false || (time() - $t) > 86400;
}

const FRESH_TIERS = [['fresh', 14400], ['aging', 86400], ['stale', 172800]];

Four hours is not arbitrary: it is how often the VP bot rewrites its scan index. Desk stamps land on the VP's eight-hour sweep and whenever a desk finishes a slice, so fresh is a deliberately tighter bar than not stale. And the twenty-four hour edge in the tier table is the same number as the one in is_stale(), because fresh plus aging has to be exactly "not stale". If those two ever drift apart, the board and the floor start telling different stories about the same desk. A generated manifest in the repo asserts the relationship and exits non-zero when it stops holding.

None of this is novel, which is the point. Prometheus has done the same thing for years: when a target stops exposing a series, the samples go stale, and the query docs note the lookback window is five minutes by default and settable with --query.lookback-delta. The heartbeat-plus-threshold shape is old enough that you can read it in patent language: US 12,475,151 B1, an individually assigned patent granted November 18, 2025, describes a multi-agent system that tracks a last-heartbeat timestamp, marks an agent "suspect" once a timeout is exceeded, then probes it before declaring failure. My version has no probe. It has a folder and a clock.

The board view of the agent status dashboard, in dark browser chrome. A header reads one working, fifteen idle, eight parked. Rooms are grouped in columns, and each bot row shows its name, role, state, what it is doing, what is next, and a timestamp. Most rows carry an orange STALE badge and a red line reading Sun, Aug 30 at 5:28 AM PT, four days ago, stale. Two rows are stamped three hours ago in grey with no badge. Client names are masked as Client A, Client B and Client C.
The flat board on a day when most of the studio had not filed in four days. Badges and red stamps are the classifier's output, not a style choice. The wages-saved and score figures are the office's own internal scoring, filed by each desk in its own bio, and are not a client billing figure. Client rooms and outside names are masked in this capture.

Why does the floor rebuild sometimes and patch the rest of the time?

Because redrawing a 3D office every ten seconds would cost real battery for no information. The poller keeps a hash per bot covering its status, its daily report, its bio, its stale flag and its top-of-room mark, plus a roster key made of every room slug and the ids seated in it. Then it branches three ways.

If the payload is byte-identical to the last one, the request comes back 304 with an empty body and nothing on the page moves except the age clock. If a per-bot hash changed, exactly that desk is patched and the line that moved fades in. If the roster key changed, and only then, the scene is rebuilt from scratch.

Three lanes describing the three outcomes of one poll. Nothing moved: HTTP 304 with an empty body, most polls end here, and the header age clock runs on its own thirty-second timer because a 304 returns before the poll body. Per-bot snapshot changed: patch that one desk, state drives the lamp and screen glow, only text that moved fades in. Roster key changed: rebuild the scene, which is rare and happens when a bot is added rather than when one works. Underneath, a live frame-rate guard: a median over forty-eight frames, a step down after two seconds under forty-eight frames per second, a rule that treats a narrow spread as a display cap rather than a cost, and a step-down that never climbs back.

The 304 path is the one I nearly threw away. My first cut of the freshness feature put a live per-second age on every desk, which would have changed the response hash on every poll and retired the empty-body path completely: a full payload every ten seconds, forever, on whatever connection I was on. The fix was to ship only the tier from the server and let the browser derive exact ages from the ISO stamps it already had. It was caught before the branch merged, and then re-proven over real HTTP: three consecutive polls, same tag, empty bodies.

Underneath all three branches there is a frame-rate probe, because guessing at device capability from a user-agent string is not measurement. It takes a median over a rolling window of forty-eight frames, and if that median sits under 48fps for two full seconds it turns the shadow map off, drops the pixel ratio to 1 and caps the lamp pool. One detail there cost me a debugging session: a display running at a 30Hz cap produces perfectly even frame intervals with the GPU idle, which looks exactly like a scene that is too heavy. A window whose tenth and ninetieth percentile intervals sit within 3ms of each other is now read as a cap, not a cost, and left alone.

Can you feel data age instead of reading it?

This is the part I like most and the part with the least evidence behind it, so I will flag it as taste rather than a finding. The server rolls every non-parked desk into one studio freshness tier, and that tier sets the daylight in the 3D office. Fresh is late morning. Cold is dusk with the lamps up. The change eases in over about a second, so a floor going dim is something you notice out of the corner of your eye before you have read a single timestamp.

The first version of this was rejected by exactly one user, which is me, on the grounds that it looked like a brown cave. So there is now a minimum brightness pinned in code, applied after the daylight table rather than inside it, which means a future edit to the table cannot reintroduce the cave. Measured, the coldest state sits at 143 of 255 mean brightness, about 45 points below fresh, and still completely readable. One more small thing: the first tier the page hears about lands instantly instead of easing. Fading from noon to dusk on load reads as the page struggling, not as a fact about the data.

The 3D office floor in browser chrome. Five rooms sit on a warm floor: the studio room with six desks, a room named for my own counselor directory, and one room per client, each with four or five desks. Figures sit at desks with monitors and lamps. Name plates float above each desk with the bot's name and role. The header reads three hours ago, data age. Client rooms are masked as Client A, Client B and Client C.
Five rooms: the studio, my directory product, and one per client. Each figure is a desk with a status file behind it. Client rooms are masked here; on my own screen they carry a first name and a last initial.

Where do two different runtimes share what they know?

In a markdown directory in the same repo, committed with the work. An index, a decisions file, an open-questions file, and dated topic notes. Both runtimes read it at the start of a session and append at the end, the cheapest version of the pattern I wrote up in knowledge that ships with the feature.

It failed once in a way worth recording. A hand-written index in that folder claimed, across at least three rewrites, that a function was dropping three fields the card needed. It was not: the mapping and the rendering had both been in place the whole time. Two agents took orders from that sentence and neither could see the problem, because the claim was prose and nobody checks prose. The fix was not to correct the sentence, which would have evaporated at the next rewrite. It was to generate the claim: a script that traces every field from the data file through the API to the view and exits non-zero when a claim stops being true. If the generated manifest and the notes disagree, the manifest wins.

The route that shipped before its lock

On a Sunday at the end of August I pushed a working page of prospect notes into that repo, for my own outreach, with contact details in the underlying JSON. The repo root is the document root on that subdomain, so a new folder is a new public URL the second it is pushed. There was no gate on that path.

The commit stamps tell the rest: the page landed at 12:03pm, a login gate on that folder went in at 12:13pm, and at 12:17pm I stopped treating it as one folder's problem and put a single door in front of everything.

# Everything goes through gate.php and the board login.
# index.php and api.php gate themselves; .well-known
# stays open so certificate renewal keeps working.
RewriteEngine On
RewriteRule ^$ - [L]
RewriteRule ^(index\.php|api\.php|gate\.php)$ - [L]
RewriteRule ^\.well-known/ - [L]
RewriteRule ^(.*)$ gate.php?p=$1 [QSA,L]

The rule I took from it, and now apply everywhere: a new route starts closed. Not "closed once the feature is finished", not "closed before launch". Closed in the same commit that creates it, so that opening something is always a deliberate act with a reason attached. An allow-list at one door is also far easier to reason about than a deny-list scattered across folders, which is why the second fix replaced the first one within four minutes.

There is a footnote worth knowing if you sit behind a CDN. A CDN will happily go on serving a 200 it saw before you tightened the door, because locking the origin does not evict what the edge already holds. So the origin's own answer is not the whole answer. Purge, then re-verify a cold miss with a plain request and no session, after any change to authentication.

When does a status file stop being enough?

Quickly, if your agents are sub-agents inside a single request. A file tells you a worker's last self-reported state. It cannot tell you which tool call burned the context window, which retry loop spun, or what one agent's decision cost the one downstream. Anthropic's June 13, 2025 write-up of their multi-agent research system is direct about why that matters: "Agents make dynamic decisions and are non-deterministic between runs, even with identical prompts", and they measured that "multi-agent systems use about 15× more tokens than chats". At that multiple you want per-call spans and token counts, not a heartbeat. I have written before about where my own AI bill actually went, and it was not a place a status file could have shown me.

The standard answer there is OpenTelemetry's GenAI semantic conventions, which now define spans like create_agent, invoke_agent, invoke_workflow, plan and execute_tool. Worth knowing before you build on them: they are still marked Development, not Stable. The agent spans document carries that status at the top of the page, and an audit published on July 17, 2026 found that "no GenAI-specific span, event, metric, or attribute in the dedicated repository is marked Stable", after all the gen_ai.* content moved out of the main semantic-conventions repo in the v1.42.0 release on June 12, 2026. That is a fine thing to adopt with your eyes open. It is not a thing to adopt because a dashboard needs to know whether a bot filed today.

So the honest boundary is this. Files are for coarse, long-running, independent workers where the failure you actually fear is silence. Traces are for fast, nested, dependent calls where the failure you fear is a wrong turn three levels down. I run both kinds of work, and I would not use one tool for both. The wider argument about owning versus renting this kind of tooling is in the rent-or-own question, and how the pieces fit together sits in the anatomy of an agent harness.

What I would build first, if I were starting this today

The classifier, before the office. Unknown, fresh, stale, on the server, computed once. Everything visual after that is decoration on top of a decision that is already correct, and decoration is easy to redo. The one addition I have made since is a scheduled scan that runs twenty-five minutes after the bots' own sweep, fetches the repo, counts how many desks filed inside twenty-four hours, and requests the live URLs with no session to confirm that everything except the front door still answers 401. It reads and reports, and it has no write access to anything. That is the verify step every agentic loop in production lives on, pointed at the monitoring rather than the product. That job found the CDN cache. It is also the reason I believe the floor when it tells me the lights are on.

Common questions

How does a status file work as an agent heartbeat?

Each bot rewrites one small JSON file at monitor/status/<slug>.json with its state, a one-line "doing", and an ISO 8601 "updated" stamp, then commits it with the work it describes. The dashboard reads the folder and treats "updated" as the heartbeat. No bot writes another bot's file.

When should a bot be marked stale?

In my office, when its stamp is older than 24 hours by the server clock, or when it carries no timestamp at all. A missing file is a separate state, unknown, and that desk is drawn empty rather than guessed at. Between those sits a four-hour sub-tier called fresh: four hours is how often the VP bot rewrites its scan index, while desk stamps land on the VP's eight-hour sweep and whenever a desk finishes a slice, so fresh is a deliberately tighter bar than not stale.

Why not use a message bus or a database for multi-agent monitoring?

For coarse, long-running, independent workers there is nothing to broker. The failure you fear is silence, and silence is exactly what a missing or old file shows. Files in git also give you history, review and deploy for free, and the whole design has only two failure modes to reason about.

When is a status file not enough?

When your agents are sub-agents inside a single request. A file cannot show which tool call burned the context window or what one agent cost the next. That is what per-call tracing is for, though OpenTelemetry's GenAI conventions were still marked Development as of July 2026.

Related

← All insights