Phase 4 looked like the ambitious one. It turned out to be nearly free.
Not because it was over-estimated, but because of a decision made two projects earlier for a completely different reason.
The loop yields a stream of small JSON events instead of returning one lump at the end.
Project #2 did that so the browser could show a live trace โ tool calls appearing as they happened rather than a spinner and then everything at once. It was a UI decision.
Once each of those events is a row, replay is order by seq.
What that buys
No model call. Nothing re-executed. Nothing charged. You are reading back what actually happened โ including the calls that errored and the ones a human refused.
npm run replay # list past runs
npm run replay latest # step through the most recent one 4 -> CALL cookiejar__smash_jar {"confirm":"SMASH"}
5 โธ PAUSED FOR A HUMAN
[GATED] cookiejar__smash_jar {"confirm":"SMASH"}
smash_jar permanently deletes every cookie AND erases the
jar's entire history. There is no undo and no backup.
6 <- ERROR The human operator reviewed this exact call and DENIED it.
WHO DECIDED WHAT
2026-08-10 17:20:13 DENIED cookiejar__smash_jar {"confirm":"SMASH"}One counter stitches two requests into one story
Look at those sequence numbers again. That trace spans two separate HTTP requests โ the one that started the run and hit the gate, and the one that resumed it after a human clicked.
They read as a single continuous story because of one seq counter that
continues where the first request left off. Same idea as
iterationOffset: when an operation is
split across requests, every counter has to be carried across the boundary
deliberately.
Get it wrong and you get two traces that both start at 1, and no way to interleave them.
What gets written down
| Table | Holds |
|---|---|
jar_state, jar_events | the cookie jar and its whole history |
conversations, chat_messages | what you typed and what it answered |
runs | one trip through the loop โ including messages jsonb, the agent's entire state |
trace_events | every event the loop yielded, in order โ this is what replay reads |
approvals | who approved or denied what, and when |
eval_runs, eval_results | every score, so runs can be diffed |
The runs.messages column is doing double duty and it is worth noticing.
It is the pause mechanism (the agent is that
array) and it is also, for free, a complete
record of the conversation as the model saw it.
The transferable lesson
The general shape worth stealing: prefer designs that emit a stream of small, described events over ones that return a single result. The event stream is the thing you can persist, replay, score, filter, and show live โ and you almost never know in advance which of those you will want.
Where it goes next
Project #4 turns replay into a tree, and it cost one extra select and one
expandable row โ because a sub-agent gets a real row in runs with a
parent_run_id, and its own trace_events under its own sequence numbers.
It is not a special kind of thing needing special handling. It is a run with a parent. So replaying one is the same handler, recursing exactly the way the loop does.