Every agent framework gives you a list of your agents. Almost none of them can tell you which one did something while you were away.
That is a different question from "what is running." Running is cheap to render: you poll a status endpoint and paint a dot. The expensive question is the one a person actually asks when they open the laptop in the morning, which is "what happened since the last time I looked at this." Answering it requires storing something almost nobody stores: the moment the human looked.
I shipped the thread column for the Vodou Console on 2026-09-09. Three public files, one commit: MCP-servers/Vodou-Console/public/index.html, public/css/08-rail.css, public/js/views/chat.js. What it does for a person is small and boring. What it took to make the "new" marker honest is the part worth writing down.
Seventeen rows in insertion order, and a dot whose meaning lived in a tooltip
The column before this was one group called "Vodou" holding 17 rows in the order they were inserted. Heartbeat, the Board, hand-driven consoles, cron-driven consoles, all the same shape. A console on a schedule got a small dot; the dot's meaning ("next run 9:05 AM") lived in a hover tooltip, which on a touchpad means it lived nowhere. "New chat" and "recently closed" were two dashed tiles at the very bottom of the chats list, below the fold on a 1440 screen.
Now it is three groups. Vodou holds Heartbeat, Board, pinned automations and hand-driven consoles A to Z. Scheduled holds the cron-driven consoles, soonest next run first, paused ones last, with the next run printed at the right edge of the row instead of hidden in a tooltip: "9:05 AM", "30m", "overdue", "paused", with a weekday prefix when the run is not today. Chats puts "New chat" as the first row and the newest chat directly under it.
The weekday prefix is a fix for a real misread: a bare "7:00 PM" on a row you look at in the evening reads as today's 7 PM, which is in the past, so the row looks broken. Any run outside today now carries its weekday.
The watermark I almost wrote on page load would have meant nothing is ever new
Here is the design I started with, and it is wrong in a way that passes every test you would think to write.
The Scheduled group should open by itself when something ran since you last looked, and stay collapsed when nothing did. So: on load, compare each console's nextRunAt against a stored snapshot of that value. If the next run moved forward, a run fired in between. Mark those rows. Write a fresh snapshot. Done.
That last sentence is the bug. If the snapshot is written on every load, then the load that would have shown you the badge also erases the evidence. Worse in the common case: the group starts collapsed, the render still runs (the rows are in the DOM, just hidden), the snapshot still gets written, and the feature is dead forever. You would see it open exactly once, on the very first visit, and then never again. Every unit test I could write against the comparison function would be green, because the comparison function was never the broken part.
The fix is four lines and it is entirely about when you are allowed to write:
// Only the act of seeing writes the watermark.
_snapshotScheduledSeen(tabs) {
const wrap = this._scheduledTierWrap;
if (!wrap || wrap.classList.contains('is-collapsed')) return;
const snap = {};
for (const t of tabs) {
const m = this._skillConsoleMeta[t.conversationId];
if (m && m.nextRunAt) snap[t.conversationId] = m.nextRunAt;
}
localStorage.setItem(this._scheduledSeenLsKey, JSON.stringify(snap));
}
Rendered is not seen. Collapsed is not seen. Opening a row is also seen, so clicking into a console drops its own mark on the way in.
Who is allowed to write the watermark
The other thing that broke was smaller and dumber. A console titled ๐ก Growth ยท Signal Hunt rendered its avatar as \uFFFD G. The initials code took raw[0], and raw[0] on a string starting with an astral emoji is half a surrogate pair, which is not a character. The middle dot also counted as a word. Both are the same mistake: treating a JS string as an array of characters when it is an array of UTF-16 code units. The version that shipped splits on whitespace and separators, keeps only the segments that contain a letter or a digit, and uses Intl.Segmenter with grapheme granularity to pull a leading emoji out as the row icon (a title that leads with an emoji now shows it once, as the icon, not twice).
I also deleted a "keep one" rule I had written earlier. Untouched "New Chat" shells were being kept across reloads, the most recent one surviving, which meant an empty placeholder you never typed in outlived the chats you did. Now a shell only survives if it is the tab you were actually on.
A watermark that a hidden element writes is not a watermark
The transferable version is a property of your codebase, and you can go check whether it holds:
Every "since you last looked" marker has exactly one writer, and that writer runs only on a path that proves a human saw the thing. Not page load. Not render. Not fetch. Not a WebSocket message arriving. Visibility, expansion, focus, or an explicit open.
The failure is invisible in tests because both halves work. The comparison is correct. The storage is correct. The marker never fires, because the same code path that reads it also writes it, and the write wins. This is the same shape as a read-receipt written by the server when it sends the message, or an "unread count" reset by the poller instead of the viewer.
Rewind your own watermark two entries and count the badges
Five minutes, nothing of ours involved.
First, is there a watermark at all? If your runs live in a database, this either returns rows or it does not compile:
SELECT a.id, a.name, count(r.id) AS runs_since_you_looked
FROM agents a
JOIN runs r ON r.agent_id = a.id
LEFT JOIN agent_views v ON v.agent_id = a.id AND v.user_id = :uid
WHERE r.finished_at > coalesce(v.last_viewed_at, '1970-01-01')
GROUP BY 1, 2;
If you have no table like agent_views, that is the finding. Your UI cannot answer "what happened while I was away" no matter how good the list looks, and every badge you show is derived from something else (usually session start, which resets the moment the tab reloads).
Second, find every writer and ask what proves a human was there.
grep -rnE "last(_|)(seen|viewed|read)|watermark|markAllRead|seenAt" \
src/ app/ --include='*.ts' --include='*.js' --include='*.py' | grep -v test
For each hit, walk up to the nearest function that is called by a render, a poll or a route handler. If any writer is reachable without a visibility check, a focus event or a user gesture, it will eat your badges.
Third, the three-load test. Do this in the browser console of your own app:
const KEY = 'your-last-seen-key';
// 1. Load the page with the panel OPEN. Snapshot exists?
console.log(JSON.parse(localStorage.getItem(KEY)));
// 2. Collapse the panel. Reload twice. Re-read the key.
// PASS: the value is unchanged from step 1.
// FAIL: it moved. A hidden element just told your app you looked.
// 3. Rewind exactly two entries by an hour and reload.
const w = JSON.parse(localStorage.getItem(KEY));
for (const k of Object.keys(w).slice(0, 2)) {
w[k] = new Date(Date.parse(w[k]) - 3600e3).toISOString();
}
localStorage.setItem(KEY, JSON.stringify(w));
// PASS: exactly those two rows come back marked, and the count says 2.
// FAIL: zero marked (a writer beat you), or all marked (you are comparing
// against session start, not against a stored per-item value).
That is the exact check I ran before shipping: open and snapshot, collapse and confirm no write, rewind two entries, reload, see "2 new" on exactly those two rows. The find field went from 26 rows to 2 on a two-character query, which is the other thing worth confirming while you are in there.
The ordering fixes are written up. The act of looking is not.
The existing literature on this is all about getting the list right, and it is good. Twenty found that their AI chat message parts came back from TypeORM with no ORDER BY on orderIndex, so reasoning blocks rendered below the answer they preceded. The ac7 web shell found DM views were recomputing the whole activity pipeline on every arriving row, quadratic in turn count, and fixed it with windowed lists and lazy history paging. AgentConnect built a merged conversation view with a SessionRail that interleaves N per-agent sessions into one timeline while keeping the private THINK and TOOL lanes attributed.
Ordering, performance, attribution. Three hard problems, all solved on the producer side. None of them store when the consumer looked, and you cannot derive it from anything they do store. Anthropic's advice to find the simplest solution first is right here too, and the simplest honest solution is one small per-viewer map, written on one path. Agent Surface frames the general version well: a surface has to be legible to whoever is reading it, and "what changed since you" is the part of legibility that needs state.
The signal is an inference, not a receipt, and it is per browser
Two live limits.
The marker infers a run from nextRunAt moving forward. That is true when a cron fires. It is also true when you edit a console's schedule by hand, which moves the next run forward without anything having executed. Edit a cron, reload, and that row reads as "new" when nothing ran. The honest version reads a run receipt from the scheduler, and that is the next thing I owe this feature.
And the watermark lives in localStorage, so it is per browser profile. Open the console on a second machine and everything is new exactly once, then correct forever after. For a local-first single-operator tool that is the right trade, and it is still a trade: it means "when you looked" is a fact my UI knows and my server does not.
Source: A 'new' badge is a lie unless something records when you looked by Chad Priest, from Building Vodou in Public.


Top comments (1)
"Running is cheap to render, what-happened-since-I-looked requires storing when the human looked" - that is the whole unread-badge problem in one sentence. Unread is a property of the observer-message pair, not the message, and almost every framework stores it on the message.
The watermark pattern generalizes past UIs too. Anything that polls - a person opening a laptop, a scheduled job, one agent reading another's outbox - wants "give me what is new since my last cursor", and the cursor has to be the observer's own recorded state, not a server guess. Once you store last-looked honestly, half the notification noise in a system disappears on its own.