A console leaked one CSS class and hid 11 days of receipts
Two ways an agent console lied during a redesign: a view's opt-out class nothing removed, and a receipt table no page read. With a five-minute check for each.
Every agent system I have seen writes more about a turn than it shows. The model ran. The memory lane ran, or did not. The tools returned. Somewhere a row landed with all of that in it. Then a person opens the console and sees whichever subset the last engineer to touch that view found interesting. The console is the one place a human checks whether the system did what it claims, and it is built by the people who are already sure it did.
I spent four days inside the console for Vodou: 13 commits, 138 files, and a staging copy of the whole UI served one URL apart from the original. Most of that was tokens, spacing, and a rail. It does not belong in a post. Two of the bugs do, because in both the console told a story the data did not support, and both have a check you can run on your own stack this afternoon.
A byte-for-byte copy at /next/, and a fifth tab that reads a table nobody read
The redesign does not replace the console. It lives at MCP-servers/Vodou-Console/public/next/ as a copy of the original, served by the same gateway at /next/index.html, with asset paths rewritten and no service worker. The old UI at / stays untouched for the whole release. I wanted the two comparable one URL apart, in the same session, against the same data, because a redesign judged from screenshots is judged by the person who made it.
Navigation became data. js/shell/nav-manifest.js lists five destinations, Settings, and a reach list for the command palette, and the rail renders from it. One status dot folds the websocket, kernel, scheduler and channel signals, reusing the polling that already existed instead of adding more.
The part that matters to you is the Receipts tab. Every turn already produced a receipt: which memories were injected, which lanes ran, how long each took, whether the turn was degraded. Memory now has a fifth tab that shows every receipt from the last 3, 7 or 14 days, grouped by local day, across all conversations, with a per-lane coverage strip (with memories, found nothing, never ran) and a “problems only” toggle.
Four records of one turn, and the fullest one was displayed by nothing
Here is the unflattering part. In August the memory pipeline went dark for 11 days. Every turn still wrote a receipt. The receipts said, per lane, “never ran, 0 ms.” Nobody saw them, because the only renderer was the small ▶ Context N line under a reply, inside a conversation. You had to open the right conversation and expand the right line to learn the lane had not run. Nobody opens old conversations to audit them. I did not.
When I went looking, I counted four records of every turn, each showing a different subset. The panel and the page injector showed memories, tools and skills. The second console showed memories, model and tokens. The stored turn_receipts row held memories, ids and the degraded flag, and no page read it. The most complete record had zero readers. It was a write with no reader, which for a human is the same as no write.
The fix was less code than the diagnosis. GET /api/receipts is a read-only query over turn_receipts. Classification lives in browseReceipts in turn-receipt.ts, which is the fixture-gated twin of the grader that already scored this flow in CI, so the tab and the grader cannot disagree about what “never ran” means. The receipt DOM moved verbatim out of chat.js into components/turn-receipt-view.js, so chat and the tab render one receipt UI instead of two that drift. Moving it exposed a second bug: parseReceiptLanes had been dropping the items count the row carried, so a lane that injected two memories rendered without the number. The morning of the blackout, with “problems only” on, now reduces to five red rows.
Visit /board once and /memory renders at padding 0 with overflow hidden
The second bug was smaller and, per minute of user time, worse. The console hands one element, #main-content, to every view. The Kanban board opts out of the standard padded document chrome by adding a class: .board-view sets padding to zero and overflow to hidden, because the board scrolls its own columns. Nothing removed the class. Visit the board once and Memory, Apps, Messaging, Skills, Scripts, Servers, Channels and Projects all rendered flush to the edges with their internal scrolling clipped until a reload.
I measured before touching anything:
/memory cls="" pad=24px/32px
/board cls="board-view" pad=0px/0px
/memory cls="board-view" pad=0px/0px <- leaked
The router already normalized display on that element, for exactly this reason. Someone had hit this class of bug before and fixed the half they saw. The class list was the other half. The overflow: hidden half did the damage: any long page opened after the board could not scroll, and the user’s report would be “Memory stopped scrolling,” which points at Memory.
I audited all 21 routes on a clean load first. Every one was already at 24/32. Nothing needed per-view spacing work. They were correct and were being stripped after the fact, which is why a review of any single view found nothing wrong.
The reset set must cover the mutation set, and every table needs a reader
Both bugs are the same shape, and the shape is checkable rather than advisory.
For the shared host: the set of properties a router resets on navigation must be a superset of the set of properties any view is permitted to mutate on the shared element. This is true or false of a codebase. Grep every view for writes to the host (classList.add, style., dataset., attribute sets) and compare that list to the router’s reset. Any property in the first list and not the second is a leak waiting for the right visit order.
For the receipts: every table the turn path writes must have at least one read path that reaches a page a person can open without already knowing a conversation id. A record reachable only from inside the thing it describes is not an audit surface. It is a footnote.
Hash-walk your routes twice in DevTools, then count readers per table
Run the first check in the browser console of your own single-page app. Replace the selector and the route list.
const host = document.querySelector('#app-main');
const routes = ['#/chat', '#/board', '#/memory', '#/settings'];
const snap = (r) => {
const cs = getComputedStyle(host);
return { r, cls: host.className, pad: cs.padding, ov: cs.overflow };
};
const rows = [];
for (const r of [...routes, ...routes.slice().reverse()]) {
location.hash = r;
await new Promise(res => setTimeout(res, 400));
rows.push(snap(r));
}
console.table(rows);
Passing output: each route’s row is identical on both passes. Failing output looks like mine above. A route whose cls or pad differs depending on what came before it has a view that mutates the host without a symmetric teardown, and the router is not covering for it.
The second check is a grep against your own tree. List the tables your turn path writes, then count readers.
sqlite3 app.db "SELECT name FROM sqlite_master WHERE type='table';" |
while read t; do
w=$(grep -rlE "INSERT INTO $t|UPDATE $t" src | wc -l)
r=$(grep -rlE "FROM $t" src | wc -l)
echo "$t writers=$w readers=$r"
done
Passing: every table with writers has readers, and for each reader you can name a page that shows the rows without a conversation id. Failing: a line like turn_receipts writers=2 readers=0. That line existed in my tree for months.
The advice is about signing the receipt, not about reaching it
The current guidance on agent receipts is about integrity. The agent-receipts project signs every memory observation with Ed25519 and hashes inputs and outputs, so a fact can be traced to the conversation that created it. The kriya console turns everything an agent did into signed receipts you can re-verify offline. Both are good work, and to their credit the agent-receipts dashboard puts a fourteen-day receipt volume chart and a recent receipts list on its front page from day one, which is the surface I was missing.
What the integrity framing skips is reachability. A signed receipt inside a conversation nobody opens is exactly as invisible as an unsigned one. My receipts were correct for 11 days. The failure was not in the record. It was that the only reader of the record was the chat view, and the chat view is the last place a person goes to audit.
The broader agent guides have the same gap. Anthropic’s Building Effective AI Agents argues for simple composable patterns and for measuring performance, and OpenAI’s practical guide to building agents covers guardrails and human intervention. Neither says where the human looks. A console is the thing the human looks at, and it is built with the least rigor of anything in the stack, because the people who build it already believe the system.
The column deserves one sentence. I added an 860px reading column to chat in the morning because every design reference has one, and removed it the same afternoon because I use the console on a wide screen and the conversation should take the width it has. The token stays at none so it is one line to bring back.
Still live on September 2: three receipt renderers became two, not one
The receipt DOM is shared between chat and the Receipts tab now. The second console has its own renderer and I did not touch it, so there are still two ways a receipt is drawn, and the panel and page injector still show their own subsets. The invariant I stated above holds for turn_receipts today. It does not yet hold for the other three records of a turn, and I have not run my own grep across all of them.