DEV Community

Cover image for Your message table is storing your UI, not your transcript
Chad Priest
Chad Priest

Posted on Originally published at blog.vodou.ai

Your message table is storing your UI, not your transcript

Every agent stack I have read persists the turn. Almost none of them draw a line between what happened and how it looked when it happened. The reply text goes in a messages table, and whatever the model or the orchestrator wrapped around that text goes in with it: the step banners, the collapsed <details> block showing which tools ran, the little header your CLI printed because a human was watching.

That is presentation, and once it is in a row it is data. Every future reader UI inherits it. Every export inherits it. Every share link inherits it. You cannot restyle your way out of a string that is already saved.

I measured my own store before touching any code. 712 stored messages carried <details> receipts across 180 conversations. 2,797 carried step banners or other terminal chrome. My plan only knew about the first number. The banners were the bulk, and nothing in the system had ever counted them.

4 memories Β· 2 tools Β· 1 skill is a claim you can only make with data

The thing I actually wanted on screen is one line under each assistant reply saying what the turn did. Not prose about it. Counts: memories retrieved, tools called, skills run, in a chip strip the surface renders from structured fields.

ChatGPT structurally cannot render that line. It never did anything to report. That line is the whole product claim for a local agent with memory and tools, and my console had been rendering it as debug HTML pasted into the message body, or not rendering it at all.

So it became an event. MCP-servers/Vodou-Console/src/turn-receipt.ts owns the shape, three chat lanes call it (POST /chat, the WS chat lane, the WS skill lane), and the gateway emits a turn_receipt frame just before done. Storage records the answer. The surface renders the counts.

Before: the assistant row holds details blocks and step banners and the panel regexes the reply. After: the row holds the answer and a separate turn_receipt frame carries the counts.

The clean flag was threaded end to end, and one caller passed a hardcoded false

The first fix looked like a one-liner. The formatter already had a clean parameter wired the whole way down from the gateway into the engine's brain loader. The gateway was simply asking for terminal chrome and then saving the result. Flip it to true and stop.

Two things made it not a one-liner.

The background re-warm passed its own args, and it still asked for chrome. Its cached output replays on the next turn, so a clean live path plus a dirty warm path produces banners again one turn later, which reads exactly like the fix not working.

And there was a second entry point into the engine's context loader that passed the flag hardcoded, not threaded. Different caller, same formatter, invisible from the gateway side. A presentation flag that any call site hardcodes is a flag that is wrong somewhere; you just have not found the surface that reaches it yet.

?::Bash shipped as a chip, because CLI tools stream with no server name

The receipt logic was not new. The browser panel lane had built one for months, and its semantics were earned rather than obvious. Two of them came from real defects.

Chips were rendered as ${server}::${tool}. CLI tools stream with no serverName, so users got chips reading ?::Bash. There is now a guard for that shape.

And the receipt is silent when there is nothing to say. It never prints "0 memories". A zero reads as a failure to a user, not as an honest absence.

That is why P1 was an extraction and not a rewrite. The panel and the console both import the one module now; the panel shed 67 lines and re-exports the type so its callers never noticed. A receipt that disagrees with itself across two surfaces is worse than no receipt at all, because now the user has to decide which one is lying.

Three chat lanes call one receipt builder, which emits a turn_receipt frame consumed by both the console and the browser panel

One builder, three lanes, two surfaces

2,798 rows matched the pattern. 544 were mine.

Stopping new noise does not clean old rows, so I wrote a stripper (scripts/strip-transcript-chrome.py, reversible, and it reclaimed 775,987 bytes across 540 rows). Before writing a single regex I sampled the actual matches. Three traps, and each one alone would have destroyed content.

LIKE '%## Step %' also matches ### Step 1 of 5, which is my own skill-creation wizard's prose, and ## Step by step, which is an ordinary LLM heading. Roughly 2,700 of the 2,798 matches were false positives.

325 of the matches were user-role rows. Those are <active_context> blocks: the prompt the model was actually sent. Rewriting a user row would not be cleanup, it would be falsifying the record of what the model saw. Assistant rows only.

14 <details> blocks looked foreign and were not. They were titled πŸ” Raw OI Results, the pre-rename spelling of my own receipt. Whitelisted, not skipped, because they are ours and they should go.

True target: 544 candidates, 540 changed.

A naive LIKE matched 2798 rows, only 544 were真 candidates, and 540 were rewritten

Rows matched vs rows that were actually ours

One more, after ship: the receipt under-reported on the heartbeat lane and on skill-console turns. The persist step re-derived the turn id instead of using the one the builder had already been handed, so tools got attributed to a turn nobody was looking at. Fixed in 21ba5202. Deriving an identifier twice is deriving it differently.

The invariant: a stored message contains nothing a renderer could compute

State it as a property of your codebase, so you can go and check whether it holds:

No field a surface could render from structured data appears inside a persisted message body. Tool counts, step headers, collapsed receipts, spinners, "Running tool 2 of 3". If it is derivable, it is a projection, and projections belong at read time.

Corollary, which is the part that bit me: a presentation flag must be a parameter at every call site, not a default at one. Grep for hardcoded values of it. The path you never think about is a cache warm or a secondary entry point.

Five minutes: GROUP BY role over your own message table

Pick three strings only your renderer emits. Mine were <details, ## Step, and a banner glyph. Then run this against your own store, whatever it is:

SELECT role,
       SUM(content LIKE '%<details%')     AS details_rows,
       SUM(content LIKE '%## Step %')     AS step_rows,
       COUNT(*)                           AS total
FROM messages
GROUP BY role;
Enter fullscreen mode Exit fullscreen mode

Passing looks like zeros on every row that is not the user's own prompt payload. Failing looks like mine did: a four-figure number under assistant.

Now the trap, and this is the half people skip. Before you write any UPDATE, read twenty of the matches:

sqlite3 -line app.db \
  "SELECT id, role, substr(content,1,300) FROM messages
   WHERE content LIKE '%## Step %' ORDER BY RANDOM() LIMIT 20;"
Enter fullscreen mode Exit fullscreen mode

If most of them are model prose rather than your chrome, your pattern is not a pattern, it is a coincidence. Tighten it until the sample is clean, and exclude user rows outright.

Then the receipt check. Take one turn id and answer this without parsing any reply text:

SELECT turn_id, COUNT(*) AS tool_calls
FROM tool_invocations WHERE turn_id = ?;
Enter fullscreen mode Exit fullscreen mode

If the only way your UI can say how many tools ran is a regex over the assistant message, you do not have a receipt. You have a screenshot of one.

The transcript-as-source-of-truth work assumes the transcript is clean

The best current thinking here is about durability, not purity. The derived transcript view in openhuman makes the raw append-only session log the single source of truth and demotes everything else to cache, modeled on Codex rollout replay. pi-ui's transcript projection reduces session events into a TranscriptState for rendering. Both are the right architecture and both are one layer above my bug: they assume the events going into the log are events. If your producer writes a rendered string, an append-only log preserves the rendering forever, faithfully.

The divergence class is real and it shows up elsewhere. In gaia, pressing Stop acknowledged the cancel while the generation ran to completion and persisted a full reply the user never saw. In livekit/agents, an interruption truncated the stored transcription. Same shape: what gets stored is not what happened. The OpenAI agents guide and the production agentic workflows paper both cover orchestration, tool design over MCP, and guardrails in detail, and neither says anything about what your surface is allowed to commit to storage. That boundary is currently folklore.

Still open: nothing fails when a new row carries chrome

The producers are fixed and the old rows are stripped, but there is no gate asserting zero new ## Step N rows in the message table. Right now the guarantee is that I fixed the call sites I found. A test that counts chrome-bearing assistant rows created since the last run, and fails, is what turns that into a property. Until it exists, the next entry point somebody adds gets to reintroduce the whole thing quietly, and I will find out again by running a LIKE a few months from now.


Source: Your message table is storing your UI, not your transcript by Chad Priest, from Building Vodou in Public.

Top comments (0)