Appearance
Agent Decision Capture
Every hosted-agent turn makes an LLM call — a full assembled prompt in, a completion out. Agent decision capture records that call so a developer (and, later, the bot's owner) can reconstruct the full story of any recent turn: what the agent was told, what it decided, and why.
This is the data substrate of the "agent lab" family. It's built to be cheap (deduped + TTL'd, not a firehose), byte-faithful (what you read is exactly what the model saw), and invisible to the turn (best-effort — a capture failure never blocks or fails an agent's action).
What's captured
For each real-inference turn (the check-else-fold fallback path is not captured — a failed turn isn't a decision), we store four things:
| # | What | Where it's taken |
|---|---|---|
| 1 | What the agent knew — the table it held, this hand's actions, recent hands, notes/whispers | buildTurnContext's arguments |
| 2 | The prompt it sent — exact bytes | the assembler |
| 3 | The response it got — raw completion, before JSON-parsing | the LLM call |
| 4 | The decision — the messages that actually reached the table | sendToTable |
Keyed by (agent_id, table_id, hand_number, action_index) — the same grain as agent_inference_charges, so a captured turn joins its cost/charge row.
Together these answer the question the owner actually has when a decision looks wrong. Without #1 and #4 you cannot tell these apart:
- the strategy is wrong — it knew the right things and chose badly
- it knew something unexpected — the prompt was built from a memory you didn't anticipate
- it decided X and the table got Y — the choice was clamped or corrected on the way out
Those need different fixes, and #2/#3 alone can't distinguish them.
"What the agent knew" is its memory, not a message
There is no wire message that says here's the hand so far, what do you want to do? The agent assembles its knowledge:
game_stateframes overwritelatestState— the last one wins, no historyyour_turncarries only{legalActions, chipRange, turnEndsAt}— no table stateplayer_actionframes append tocurrentHandActions— this is the only way it knows the player before it raised to 40. The state shows the result (chips down, pot up), never the verb.hand_resultframes accumulatehandHistory(capped at 20)
So the composite exists only inside the agent process, only at that instant. Capturing it is the only record of what the agent actually had — as opposed to what we think we sent it. Those differ more often than you'd like: an agent reconnected mid-hand keeps playing with an empty action log and no hand history — it can see the pot but not who built it — and from the outside that is indistinguishable from bad strategy. (Recorded as a finding at .indusk/research/agent-reconnect-amnesia.md; capture makes it legible, it does not fix it.)
#1 is taken from buildTurnContext's argument object rather than a hand-written list of fields. That function renders the prompt, so its arguments are the definition of what the agent knows. A memory source added later — a new skill, a configurable history depth — reaches the prompt through there, so it lands in the trace automatically instead of being silently absent until someone notices.
"The decision" is what left, not what was parsed
action/amount are the model's parsed choice. #4 is the raw messages that went out. When a choice is clamped or corrected between the two, they disagree — and that gap is the whole difference between "bad agent" and "bad clamp".
Every outbound message goes through one door, sendToTable, enforced by scripts/check-agent-outbound-chokepoint.sh (chained into pnpm typecheck). A raw ws.send(...) would be a message that never appears in any trace: the trace would look complete and be wrong.
Messages sent outside a turn (enter, sit, stand, post_blind) pass through the chokepoint but create no turn record — they have no prompt and no response, so they aren't decisions. The buffer is scoped to a turn: it resets when your_turn arrives and drains when the turn's record is written. Both halves matter, and falsification found out why the hard way:
- Without the reset, a turn whose inference failed (sent a safe action, never captured, so never drained) bled into the next turn's decision — the trace claimed two actions on one turn. And
entercarries the agent's Ed25519agentToken, so the very first turn's decision shipped a live credential into the buffer and, on flag, Postgres. - Credentials are stripped before buffering, never before sending — the wire needs the token, the transcript never does. Redacted, not deleted: a vanished field reads as "the agent never sent one".
"What it knew" is frozen, not a live view
#1 snapshots the memory rather than referencing it. actionLog and handHistory are the connection's live arrays — mutated in place while a MIN_TURN_MS sleep of up to 3 seconds sits between building #1 and capturing it. The agent's own action even echoes back as a player_action and appends. So without a copy, the trace showed the agent knowing the action it had not yet taken.
state is deliberately kept by reference: it is replaced on each frame, never mutated, so it's already frozen.
The repeated-envelope dedup
~70–80% of every prompt is byte-identical turn to turn (the base prompt + strategy + skill sections don't change within a session; only the game state and hand history move). Storing that repetition on every turn would be a firehose (~0.5 GB/day on a busy table).
So the rendered prompt is split into two parts:
- envelope — the repeated prefix (
basePrompt+[Strategy]+ strategy + agent-skill sections). Content-addressed bysha256and stored once per distinct hash. When a skill, the base prompt, or the strategy changes, the bytes change → a new hash → a new envelope is stored automatically; old captures still reconstruct against their original hash. "Repeated" ≠ "permanent". - remainder — the per-turn part (game state + hand history).
Reconstruction is remainder.length > 0 ? envelope + "\n" + remainder : envelope — byte-identical to what the model received. The split keeps the live prompt unchanged (a golden test proves the model still gets the exact same bytes).
Proven under load
On a local run with real bots, 1,353 captured turns were stored against just 7 envelope blobs — the dedup collapsing thousands of turns into a handful of prompt prefixes, at zero cost to fidelity.
The 24-hour hot buffer
Capture lands in a dedicated Redis (AGENT_CAPTURE_REDIS_URL), isolated from the turnstore and engine Redis so a capture spike can never evict must-keep turnstore state. Every key carries a TTL (CAPTURE_TTL_SECONDS, default 24h), so the buffer self-bounds — a day of recent history, then it evaporates. Anything worth keeping longer is pinned to durable storage (see the pin-to-durable section).
The flow
Pinning to durable storage
The 24h buffer is disposable by design. Anything worth keeping longer gets pinned: POST /internal/agent-capture/promote {tableId, handNumber, agentId} copies that agent's captured turns out of the buffer into the durable Postgres table agent_call_records, where they survive the TTL. (The admin viewer's "keep this hand", and the player-facing flag, call this.)
agentId is required and scopes the pin to one agent. It has to be: a pin used to copy every agent's turns for the hand, so one owner flagging a hand persisted their opponents' prompts — carrying those agents' strategies and reasoning — into durable storage. See "Whose turns can you read?" below.
The outcome is owner-scoped too. When nothing of yours is in the buffer, promote reports already_pinned vs nothing_to_capture by asking whether your agent has durable rows — not whether the hand does. Asking the hand-wide question told an owner their hand was saved because a rival's turns had been pinned, which is exactly the fabrication the honest-expiry discrimination exists to prevent.
- Durable growth tracks pinned hands, not turns. Playing hands adds nothing long-term; only a pin writes durable rows.
- Idempotent. Re-pinning the same hand inserts nothing new (unique on
table_id, hand_number, action_index). - The envelope is stored inline per pinned hand — pinned volume is small, so no cross-hand dedup is needed durably; envelope + remainder still reconstruct the exact prompt.
getHandTurns reads the buffer and the durable store and returns the more complete set — so a pinned hand keeps returning all its turns even after its 24h buffer entry has partially or fully expired.
Pin within the window
Pinning is buffer-sourced — "Keep this hand" only works while the hand's 24h buffer is still live. Promote a hand whose buffer has expired and it reports "nothing to pin" (or "already pinned" if it was pinned earlier), never a false success. The recent-hands list is sourced from the persistent inference ledger, so it can show hands whose capture has already expired, and it marks fallback-only hands (all check-else-fold, zero real captures) — either will open to an empty turn stream.
Version attribution
Every captured turn names the published agent version that produced it (agent-decision-inspection Phase 1). The captureTurn write carries agentVersionId from the seat's AgentDeployment snapshot; promote stamps it onto the durable agent_call_records rows; the read view exposes it on every turn as agentVersionId.
Records captured before the tag existed (buffer records or durable rows with a NULL agent_version_id) resolve their version at read/promote time via a join to agent_inference_charges on (table_id, hand_number, action_index) — the charges row has carried the version since agent-versioning shipped, so no data migration was needed. A turn whose version cannot be resolved renders version-less rather than failing the read.
Reading it back
GET /internal/agent-capture/hand?tableId=&handNumber=&agentId= (service-key) returns one agent's captured turns for a hand — each with all four things (knowledge, prompt, response, decision), plus the parsed action, the model/routing, and an isFallback flag. All three params are required — the route 400s without a scope.
undefined means not captured — never render it as empty.
knowledge/decision are undefined on turns captured before agent-full-transcript shipped. That is different from [], which means "captured, and it sent nothing". A reader that collapses the two tells an owner their agent sent no messages when in fact we never recorded them — a partial loop presented as complete is the one outcome worse than a visible gap.
GET /internal/agent-capture/hand-all?tableId=&handNumber= (service-key) is the operator view: every agent's turns for the hand. The admin viewer uses this; the owner-facing path never does.
It spans both stores, and that matters: agents:{tableId}:{handNumber} is a buffer key, so it carries the 24h TTL, while pinned rows live forever. Driving the fan-out from the buffer set alone would make the viewer blind to any pinned hand older than a day — precisely the hands an operator opens the viewer to read. So the agent set is the union of the buffer's set and the distinct agent_ids on the hand's durable rows, and turns are deduped by (agentId, actionIndex) with the buffer winning.
A buffer-TTL'd key must never gate a durable read.
The agents: set is a lifetime-bounded optimisation, not the source of truth for who played a hand.
Whose turns can you read?
Operator sees all, owner sees own. That asymmetry is the boundary, and it is enforced by the key scheme rather than by a filter:
turn:{tableId}:{handNumber}:{agentId}:{actionIndex} one captured turn
hand:{tableId}:{handNumber}:{agentId} that agent's action-index set
agents:{tableId}:{handNumber} the operator's fan-out set
env:{envHash} the deduped envelope (shared)Every per-turn key carries the agent, so an owner-scoped read touches only that agent's keys — a rival's prompt is never fetched, not merely filtered out. The read/promote functions take a required agentId, so a caller that omits the scope is a compile error rather than a leak.
env:{envHash} is deliberately not agent-scoped: it's content-addressed, dedup across agents is exactly what bounds storage, and a hash reveals nothing on its own — you need the (agent-scoped) turn record to reach it.
Durable Postgres rows are hand-scoped by nature — one table, all agents — so they can't be key-scoped; the durable read filters by agent explicitly. That also keeps rows pinned before this scoping existed unreadable.
Two agents share an actionIndex
actionIndex is the per-hand action counter across all players, so it is not unique per agent — it stays the join key to agent_inference_charges, and the agent segment narrows it. A key scheme without the agent segment (which is what shipped first) makes one agent's turns reachable while reading another's.
The admin viewer
The dev instrument lives at /agent-capture in the admin app (nav card on the home page). It's the fast way to answer "what was this agent thinking?":
- A collapsible recent-hands history on top — the hands that had agent inference (from
agent_inference_charges), each shown as atableId:handNumberID with a one-click copy button and a turn count. Collapse it to focus on one hand. - Hand-ID lookup — type a
tableId+hand #(or click a history row) to load that hand. - The agent-turn stream below — per turn: the collapsible full prompt (the exact bytes the model saw), the raw response (including the agent's reasoning), the action taken, the model/cost, and a FALLBACK badge if the turn was a check-else-fold safe action rather than a real decision.
- "Keep this hand" pins it to durable storage (survives the 24h TTL).
The admin app reaches HAS through the server-side /api/hosted-agent/* proxy (the x-service-key never touches the browser).