2026-07-06
Pause, Approve, Resume, Steer
Some actions need human approval and a user may want to correct the agent mid-run. A journal-backed interrupt, resume, and steer put a human in the loop, durably.
What you’ll learn
Part 9 made the agent durable: it can crash between two steps and come back without losing state or double-charging a customer. But it still has one stubborn habit. It runs start to finish on its own, head down, never pausing to ask. Two things a real deployment needs are missing, and both of them require the run to stop and hand control to a person. The first is approval: some actions must not happen without a human saying yes, and the canonical case is money. A refund of $180.00 over a $100 policy threshold should not post until somebody with authority approves it. The second is correction: a person watching the run may want to change what the agent does next rather than only bless or block it, and a binary approve-or-deny gate cannot express “do it, but for a smaller amount.” This part adds both, on top of Part 9’s journal, with four moves. First, interrupt(): at a gated action the agent does not call the tool, it journals a pending_approval event with a token and raises a serializable PendingApproval that is caught at the top level, so the run is paused and the token is returned, not killed. Second, resume(run_id, decision): later, a human decides, and resume replays the journal exactly as Part 9 does, records the decision, and continues, with the gated refund keeping its idempotency key so an approved resume executes effectively-once even if retried. Third, steer, not just approve or deny: the decision is a first-class action, APPROVE, DENY, or STEER a correction (here, lower the refund under the threshold), and answering a clarifying question back to the user is the same mechanism. Fourth, streaming progress: because every step is already a journal event, a live feed is just a read over the same log, and the pause, the decision, and the resume all show up in it.
Prerequisites
You need basic Python: functions, a dictionary, a list, and a loop. That is all. Part 9 helps a great deal, because this part is built directly on its two ideas, the append-only journal where state is the fold over the log, and the idempotency key that makes a side effect effectively-once, but the essay is self-contained. Where it leans on Part 9 it restates the idea in a sentence and does not rebuild the journal or re-teach the ReAct loop; those are owned by Parts 1 and 9 and referenced here. The companion code, pause_approve_resume.py, runs offline with no API key, no network, and no dependencies, so you can read every line and reproduce every trace in this essay yourself. The timestamps and the run id (run-aa10) are frozen so the journal is byte-reproducible, with the real LLM path (generate()) one environment flag away, exactly as in Parts 1 through 9.
What is new since Part 9
I want to be precise about what is new, because pausing for a human is its own layer with its own failure modes. Part 9 added durability, the ability to survive a crash and a recovery, but a durable agent still runs alone: it either finishes or it crashes and resumes itself, and at no point does it deliberately stop and wait for a person. This part adds that deliberate stop. The closest thing in the earlier series was RAG Part 20’s conversational agent, but that is a different animal: its turns were same-process, a user sent a follow-up and the same live loop kept going, with state in RAM the whole time. There was no gate, no token, no second process. And RAG Part 19’s agent had only read-only tools, so it never needed approval at all, because nothing it did could move money or commit a side effect worth blocking. What is genuinely new here is cross-process pause and resume for approval plus mid-run steering, and both are durable territory, not conversational territory. The pause is a real interruption that persists to the journal and returns a token; the resume is a separate act, possibly in a separate process, that reads that journal back. The steer is a correction injected by a human between the pause and the resume. None of that existed before. This part reuses Part 9’s journal and idempotency key as the substrate, references them, and does not rebuild them; the new code is the interrupt, the resume, and the decision that can be a correction.
The interrupt
The run goes exactly as far as it safely can, and then it stops at the gate. The first step, search_policy, is read-only and needs no approval, so it runs and journals its result. The second step is the refund, and $180.00 is over the $100 threshold, so the agent reaches the gate and does not call the refund tool. Instead it journals a pending_approval event carrying a token and the pending action, and raises a PendingApproval. Here is the pause, quoted from the artifact’s real output:
step 0 search_policy: Refunds after the window are refundable minus a 10% restocking fee.
PAUSED: refund $180.00 exceeds the $100 threshold
returned token 'appr-1'; the run is persisted, not dead. Ledger so far: (empty, no money moved yet)
Read what did and did not happen. Step 0 ran, the policy text is in the journal. Then the gate stopped step 1 before it touched the payment provider, which is why the ledger is (empty, no money moved yet): no money moved, because the gate is checked before the side effect, not after. The crucial design choice is in the word persisted. The interrupt is a PendingApproval exception, not sys.exit. That matters more than it looks. sys.exit would tear down the interpreter, which is fine for a one-shot script but disastrous in a notebook or a long-lived server, where killing the process to ask a question is absurd. A serializable exception caught at the top level does the opposite: the caller catches it, persists the journal, and returns the token to whoever asked for the refund. The run is paused, not dead. The token 'appr-1' is the handle a human will quote later to resume the exact run that stopped, and the pending_approval event on disk is what makes the pause survive the process that raised it. The stream over the journal shows the same three beats:
> run started
> done: Refunds after the window are refundable minus a 10% restocking fee.
> PAUSED for approval (refund $180.00 exceeds the $100 threshold); token appr-1
That last line is the whole pause, expressed as a journal event: not a crash, not an exit, a recorded waypoint that says where the run stopped and what token will reopen it.
Resume, effectively-once
Later, a human decides. resume(run_id, decision) does not start a fresh run; it rehydrates the paused one by replaying the journal, exactly the Part 9 fold, records the decision as an approval_decision event, and re-enters the loop. The replay is what makes resume safe: it tells the loop precisely which steps already finished so it does not redo them, and which step is still waiting on the gate. Watch the approve path, quoted from the artifact’s real output:
[human] decision = approve
step 0 search_policy: memoized -> 'Refunds after the window are refundable minus a 10% restocking fee.'
step 1 process_refund: refunded $180.00 to ORD-3300
finished -> Refund of $180.00 for ORD-3300 is complete.
ledger: {'ORD-3300': 180.0}
Two mechanisms from Part 9 are doing the work here. Step 0 is memoized: the fold over the journal found its recorded result, so resume returns memoized -> 'Refunds after the window...' without re-running the search. Step 1 then runs for real, the refund posts, refunded $180.00 to ORD-3300, the run finishes, and the ledger reads {'ORD-3300': 180.0}, one refund. The reason this is safe even if resume is called twice, say a retry after a flaky network or an impatient operator clicking approve again, is the idempotency key from Part 9. The gated refund kept its key across the pause, so a second approved resume would hit a key the provider has already honored and get back the original result instead of charging again. That is effectively-once carried through a human pause: the decision to refund can be re-made on a retry, but the effect lands exactly once. The pause did not weaken Part 9’s guarantee; it inherited it. Diagram 1 traces the two phases, the interrupt that journals the token and the resume that replays and acts.
The decision is an action: approve, deny, steer
Here is the part that separates a real human-in-the-loop from a checkbox. The decision is not a binary gate. It is a first-class action, and the agent treats APPROVE, DENY, and STEER as three different things the human can do. Approve we just saw. Deny is the clean refusal: the human says no, and the run finishes without any money moving, quoted from the artifact’s real output:
[human] decision = deny
step 0 search_policy: memoized -> 'Refunds after the window are refundable minus a 10% restocking fee.'
step 1 process_refund: DENIED by approver -> no money moved
ledger: (empty)
DENIED by approver -> no money moved, and the ledger stays (empty). That is the binary half, and most systems stop there. But a binary gate forces a bad choice when the agent is almost right: the refund is justified but the amount is wrong, and approve overpays while deny helps nobody. Steer is the third option, and it is the interesting one. The approver does not just say yes or no; they inject a correction that changes what the agent does next. Here they lower the refund from $180.00 to $90.00, which drops it under the threshold, and the agent then carries out the corrected action, quoted from the artifact’s real output:
[human] decision = steer (correction: $90.00)
step 0 search_policy: memoized -> 'Refunds after the window are refundable minus a 10% restocking fee.'
step 1 process_refund: STEERED by approver -> amount lowered to $90.00 (now under the $100 threshold)
step 1 process_refund: refunded $90.00 to ORD-3300
finished -> Refund of $90.00 for ORD-3300 is complete.
ledger: {'ORD-3300': 90.0}
Read the two lines for step 1. First STEERED by approver -> amount lowered to $90.00 (now under the $100 threshold), the correction applied; then refunded $90.00 to ORD-3300, the corrected action executed; and the ledger reads {'ORD-3300': 90.0}. The human did not approve the wrong thing or deny a needed refund; they fixed it mid-run and let the agent finish the right thing. This is why the decision being a first-class action matters: a correction is something only a richer-than-binary gate can carry. And the mechanism generalizes past money. An agent that pauses to ask the user a clarifying question is doing the exact same thing, journaling a pause, returning control, and resuming with the human’s answer injected as a correction to its plan. Approval, denial, steering, and a clarifying question are one mechanism wearing four hats. Diagram 2 lays out the gate as a three-way branch, and the interactive figure below lets you drive all three resolutions against the live journal and ledger.
Streaming from the journal
There is a quiet payoff to having made every step a journal event back in Part 9. A live progress feed costs nothing extra to build, because it is just a read over the same log that makes the run durable. The pause is an event, the decision is an event, the resume’s results are events, so streaming is a fold that prints them as they arrive rather than one that reconstructs state. Here is the steer timeline, the full life of one run including its pause and correction, quoted from the artifact’s real output:
> run started
> done: Refunds after the window are refundable minus a 10% restocking fee.
> PAUSED for approval (refund $180.00 exceeds the $100 threshold); token appr-1
> human decision: steer
> done: refunded $90.00 to ORD-3300
> finished: Refund of $90.00 for ORD-3300 is complete.
Read it top to bottom and it is the whole story of the run: it started, the search finished, it paused for approval with token appr-1, a human steered it, the corrected refund of $90.00 went through, and it finished. A watcher tailing this feed sees the agent stop, sees the human intervene, and sees it pick back up, all from the same events that would let a new process resume the run after a crash. Durability and observability are the same log read two ways: replay it to a process and you get resume, replay it to a screen and you get a stream. The point of writing every step down is that you only have to write it once.
Across processes
I want to be honest about the cross-process story, because the single-file demo could mislead. A real deployment does not pause and resume in one breath. The request that hits the approval gate returns the token and ends, in one process; the run is now sitting in the journal, paused. Some time later, after a human has actually looked at it, in a different process, a later web request or a CLI re-invocation, resume is called with that token on the same journal, and the run continues from where it stopped. The two phases are genuinely separated in time and in process, and the only thing connecting them is the durable log and the token that names the paused run. The demo in this file models both phases in one program, by catching PendingApproval, persisting the world, and then calling resume on that persisted world, so you can watch the full arc in a single trace. That is a faithful model, not a shortcut: the resume really does replay the journal from scratch rather than trust an in-memory variable, which is exactly what a second process would have to do. The CLI two-process version is the same idea made literal, a first invocation that pauses and writes the journal, a second invocation that reads that journal and resumes. The single file collapses the timeline for readability; the durable log is what makes the collapse safe to undo.
💡 From experience. An approval gate once saved me from a genuinely bad day. We had an agent that processed refunds, and a malformed upstream record made it decide a customer was owed a refund roughly two orders of magnitude larger than anything reasonable. The only reason it did not post was a dumb threshold: anything over a few hundred dollars paused and pinged a human queue. I picked it up, saw the number, and instead of approving or denying I did the third thing, I steered it to the correct amount and let it finish, because the customer was owed a refund, just not that one. If the gate had been binary I would have had to deny a legitimate refund and kick off a manual ticket, or approve a number that would have triggered a fraud review and a clawback. The steer turned a near-incident into a thirty-second correction. The other lesson came from the opposite mistake on an earlier system that had no pause at all: when an agent went off the rails mid-run, our only tool was to kill the process and restart from zero, which threw away the good work it had already done and risked re-doing side effects. Being able to stop a run, nudge it back on track, and resume from exactly where it paused, instead of killing and restarting, is the difference between steering a car and pushing it off the road to start over.
Key takeaways
- Part 9’s durable agent survives a crash but still runs alone, with no way to stop and ask. Real deployments need two things it lacks: approval before a gated action (a
$180.00refund over a$100threshold) and correction of the agent mid-run, and both require the run to pause and hand control to a human. interrupt()journals apending_approvalevent with a token and raises a serializablePendingApprovalcaught at the top level, notsys.exit, so the caller persists the journal and returns the token ('appr-1') while the ledger stays(empty, no money moved yet). The run is paused, not dead, so notebooks and servers stay runnable.resume(run_id, decision)replays the journal (Part 9’s fold), memoizes the already-finishedsearch_policystep, records the decision, and continues. The gated refund keeps its idempotency key, so an approved resume runs effectively-once even if retried:refunded $180.00 to ORD-3300, ledger{'ORD-3300': 180.0}.- The decision is a first-class action, not a binary gate:
DENIED by approver -> no money moved(ledger empty), orSTEERED by approver -> amount lowered to $90.00thenrefunded $90.00 to ORD-3300(ledger{'ORD-3300': 90.0}). Steer injects a correction; answering a clarifying question back to the user is the same mechanism. - Streaming is a read over the same log. The steer timeline (
run started,done,PAUSED for approval ... token appr-1,human decision: steer,done: refunded $90.00,finished) is just a fold over the journal events that already make the run durable. Write every step down once and you get both resume and a live feed. - Cross-process, honestly: a real deployment pauses in one process (the request returns the token) and resumes in another (a later request or CLI re-invocation) on the same journal. The single-file demo models both phases by catching
PendingApprovaland then callingresumeon the persisted world, which is faithful because resume replays from scratch rather than trusting RAM.
Glossary
- Human-in-the-loop: a design where the agent deliberately stops at certain points to let a person approve, reject, or correct what it is about to do, rather than running fully autonomously; here it is built on Part 9’s journal so the pause survives the process.
- Approval gate / threshold: a policy check before a side-effecting action (here, refunds over
$100) that requires human sign-off; the gate is checked before the effect, which is why a paused refund leaves the ledger empty, no money moved. interrupt(): the act of reaching a gated action, journaling apending_approvalevent with a token and the pending action, and raisingPendingApprovalinstead of calling the tool; it pauses the run and hands control out to the caller.PendingApproval(serializable, notsys.exit): the exception the interrupt raises, carrying the token, the pending action, and the reason; caught at the top level so the caller persists the journal and returns the token. It is deliberately notsys.exit, so a notebook or server is paused, not killed.- Token: the handle (here
'appr-1') returned when a run pauses; a human quotes it later to resume the exact run that stopped, and it is recorded in thepending_approvaljournal event so the pause survives the process. resume(run_id, decision): rehydrating a paused run by replaying its journal (Part 9’s fold), recording anapproval_decisionevent, and re-entering the loop to act on the decision; finished steps are memoized so only the gated step runs.- Steer (first-class decision): a human decision that injects a correction (here lowering the refund to
$90.00, under the threshold) rather than a binary yes or no; the agent applies the correction and executes the corrected action. A clarifying question answered by the user is the same mechanism. - Streaming progress: a live feed produced by reading the journal events as they are appended; because durability already records every step, the pause, the decision, and the resume show up in the stream for free.
- Reuses Part 9: the append-only journal and the idempotency key are inherited from Part 9, not rebuilt; the journal makes the pause and resume durable, and the key makes an approved resume effectively-once even if retried.
The rule from this part: a durable agent should not have to choose between running fully autonomously and not running at all, because some actions need a human and some runs need correcting mid-flight. Turn the journal into a place the run can stop: at a gated action, journal a token and raise a serializable PendingApproval rather than sys.exit, so the run is paused and the token is returned, not killed (interrupt). Let a human decide later by replaying that journal and acting on the decision, with the idempotency key making an approved retry safe (resume, effectively-once). Make the decision a first-class action so a person can approve, deny, or steer a correction, not just flip a binary switch. And stream progress for free, because it is the same journal read to a screen. But notice what we still cannot do well: when one of these runs goes wrong in production, across the pause, the resume, the steer, and the side effects, we have a journal we can read by hand and not much else. A durable agent you cannot see into is undebuggable, and “read the JSONL by hand” does not survive contact with a real incident. Part 11, The Observable Agent, is the core capstone: fold this same journal into OpenTelemetry-shaped spans for real tracing, measure cost-per-success rather than raw token counts, and turn the agent we have been building into one you can actually operate.