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.
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:
- One typed noun.
Card = {id, kind, title, fields, actions, source, ref, state}, with kinds registered by descriptor —action,decision,ask,choice,record,progressin core; a plugin adds a kind inplugin.yamlwith a schema and a renderer name. The gate verdict, the QA finding, the release card become card kinds, not sibling components. - One wire event and one ledger.
cardandcard_update, so filed → decided → expired streams without re-rendering the turn;recommended_actionbehind a shim for one release. Rendered from the row after reload, not client-only. - Three producers, one contract. A
put_cardtool (the model's explicit door); result-to-card descriptors on the tool registry, sopropose_actionsurfaces adecisionand a gate refusal surfaces arecorddeterministically, with no model involved — finding 17's fix by construction; and the backstop, unchanged in role, emitting through the same door. - A typed decision. Every interaction posts a
card_decisionuser turn ({cardId, action, outcome}), so "approve" is never text the model has to bind. Finding 16, closed by type. - Testable by layer. Schema tests per kind; a scripted-model harness test per producer
asserting the card on the wire and on the row (finding 18's test, generalised); eval checks
cardsAtLeast: nandcardKind: decisionso a reference set can assert a decision card on every build case; one log line per card lifecycle.
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.