← All posts

Cards, not prose

Three findings in twenty-four hours — 'approve' bound to the wrong record, a card narrated as a sentence, a card lost in the auto-file round trip — were each a seam between the pieces that make a card. What shipped: the card persisted on the turn and replayed to the model as the tool call it was, written down before it is filed. What is next: one typed card contract in core. vocion-core#690, #694.

Vocion Team

Status: shipped, re-walk pending. vocion-core#690 — a turn's cards are persisted and replayed, so "approve" binds to the card it put up — merged 2026-09-25 and is live at 77b9edcc. vocion-core#694 — the card is written down and shown before it is auto-filed, never only after — merged 2026-09-25 and is live at 3179fafa. The live walk that proves the whole chain on the box is still to be re-run after #694. Docs: Cards (in progress) and Turn guarantees.

Finding 16: "approve" bound to the wrong record

The walk: a person reports a bug in chat. The product manager puts up a card — File this as a request. The person says approve. Three times in a row, the PM decided a proposal on an existing request instead of the card it had just put up.

The card lived only in the browser. The recommend_action tool emitted an event; the chat rendered it; nothing wrote it down. On the next turn the model's history said it had recommended something, in prose, with no proposal id anywhere, so "approve" went looking — and found a plausible record.

The first fix appended a bracketed sentence to the replayed turn: [Earlier in this turn you put up a card "File this as a request" → objects.propose_candidate (proposal #124)]. It worked, and Chris asked the right question the same hour: "Should card come back as metadata? Not text inline to parse?"

Yes. A stored agent turn is now replayed the way the model produced it: an AIMessage carrying its tool calls, a ToolMessage per result, then its answer. A card is the recommend_action call it was, and its result says Card "File this as a request" is on the person's screen as proposal #124. If they say "approve", "file it" or "go ahead", they mean this card: decide it (decide_proposal 124) — never a different record. The model binds "approve" to a tool call it made, not to a sentence about one. The bracketed marker survives only for the two out-of-process loops whose payload is {role, content} and cannot carry tool messages (services/chat/historyTools.ts).

Finding 17: the card narrated as prose

The next shape: the model writes out the card as a paragraph — I'd recommend filing this as a request: title …, why … — while a real card is on screen, or instead of one. Sometimes the last word of the answer was literally recommend_action, the tool's name, typed instead of called.

Two structural answers, neither a prompt edit. A tool's name written as the last word of an answer is stripped and the loop re-enters once to make the call (#679; a call imitated as a fenced block, #681). And a card the model writes out as prose while a real card is on screen is stripped from the answer — the card carries it.

Finding 18: the card lost in the round trip

With the replay in, the walk was re-run on the box (77b9edcc, run 11). The runs carried their tools. The card was never persisted or shown.

Under the done-for-you default, a recommended action above its confidence bar is auto-filed: the route takes the event, files the proposal, and only then emits the card with its proposal id. The round trip failed silently — and the card reached neither the stream nor the row. The next turn went looking for a record that did not exist.

#694 makes the order structural: the card is written to the row and emitted to the stream the moment the tool emits it, unfiled; the auto-file runs beside it with an eight-second cap; when the filing lands, the card updates with its proposal id, and when it fails or times out the card stays on screen unfiled with the log saying why. A card the person can press beats a filed one they never see.

What a card is, today

One tool (recommend_action, which validates its payload against the action's schema before the card exists and defaults the suggested decision so a card is never lost to a missing sentence — #686). One event (recommended_action). One React component. A gated backstop that runs after the answer pass so it reads the answer the person got (#685) and logs what it did (#678). A card run on the row. A structured replay. Defer as a decision with a reason and a date (#663). Cards that emerge after the answer and are decided one at a time (#667).

Six pieces, and the three findings were the seams between them. Nothing declares what a card is.

What is next: one contract

Chris, 2026-09-25: "We should have a really solid, in core, dependable and testable feature for emerging and surfacing cards in the app chat. We will want other core, plugins, agents/tools and workspaces to be able to extend this functionality. I don't know if it's a tool or…? You tell me."

A tool is the door, not the feature. The recommendation on the table, specified and not yet built:

Phase 1 is the type, the events with the shim, render-from-row, the typed decision turn, and deleting the route's auto-file branch. The Cards doc becomes the contract when it lands.

The principle

Every one of these findings was a place where the model was asked to be reliable about something the system could have been reliable about instead. The fix each time was the same shape: a typed event, a deterministic post-process, a gated backstop last. Structural over prompting is a rule in vocion-core's CLAUDE.md because a prompt that produces the right card on Tuesday produces prose on Thursday, and nobody can tell you why.