AGENTS FROM FIRST PRINCIPLES · PART 10 OF 17

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.

A two-phase diagram. Phase one, labelled INTERRUPT, shows a run lane: a box run_started, then step 0 search_policy with a green check and the result Refunds after the window are refundable minus a 10 percent restocking fee, then step 1 process_refund ORD-3300 180 dollars reaching a gate drawn as a barrier labelled threshold 100 dollars, 180 over threshold. At the gate the lane forks down to a journal row pending_approval with token appr-1 and the pending action, and an exception symbol labelled raise PendingApproval, caught at top level, NOT sys.exit. A side note reads ledger empty, no money moved, run paused not dead, token returned to caller. Phase two, labelled RESUME, shows a human icon choosing a decision that becomes an approval_decision journal event, an arrow into a replay box that folds the journal, step 0 shown greyed and labelled memoized, no re-run, then step 1 process_refund executing with the same idempotency key labelled effectively-once even if retried, ending in refunded 180 dollars to ORD-3300, finished, and a ledger reading ORD-3300 equals 180 dollars. A caption strip reads: pause journals a token and raises, resume replays and acts, the key makes it effectively-once.
Fig 1 The interrupt and resume across the journal. In phase one the agent runs step 0 search_policy, journals its result, then reaches the gated refund of 180 dollars which is over the 100 dollar threshold. Instead of calling the refund tool it journals a pending_approval event carrying the token appr-1 and the pending action, and raises a serializable PendingApproval that is caught at the top level. The caller persists the journal and returns the token; the ledger is empty because no money moved, and the run is paused, not dead, because this is an exception and not sys.exit. In phase two a human calls resume with the run id and a decision; resume replays the journal, finds step 0 already complete and returns its memoized result without re-running the search, records an approval_decision event, then executes the gated refund. Because the refund kept its idempotency key from Part 9, an approved resume executes effectively-once even if retried, so the ledger ends at a single 180 dollar refund and the run finishes.

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.

A diagram of an approval gate drawn as a single paused node branching three ways. At the top, a box reads PAUSED: refund 180 dollars exceeds the 100 dollar threshold, token appr-1, with a small human icon labelled approver decides. Three labelled arrows fan out below. The first, APPROVE, leads to a box reading execute unchanged, refunded 180 dollars to ORD-3300, with a ledger chip showing ORD-3300 equals 180 dollars. The second, DENY, leads to a box reading DENIED by approver, no money moved, with a ledger chip showing empty. The third, STEER, leads to a two-step box: first amount lowered to 90 dollars, now under the 100 dollar threshold, then refunded 90 dollars to ORD-3300, with a ledger chip showing ORD-3300 equals 90 dollars. A side note next to STEER reads a correction, not a yes or no; same mechanism as answering a clarifying question. A caption strip reads: the decision is a first-class action, approve, deny, or steer a correction, never just a binary gate.
Fig 2 The approval gate as a three-way decision, not a binary switch. The agent reaches a gated refund of 180 dollars that is over the 100 dollar threshold and pauses with token appr-1. A human resolves the pause one of three ways. APPROVE executes the gated action unchanged: refunded 180 dollars to ORD-3300, ledger 180 dollars. DENY refuses cleanly: DENIED by approver, no money moved, ledger empty. STEER injects a correction rather than a yes or no: the approver lowers the amount to 90 dollars, now under the threshold, the agent applies the correction and then executes it, refunded 90 dollars to ORD-3300, ledger 90 dollars. The point is that the decision is a first-class action: a binary gate forces approve the wrong amount or deny a needed refund, while steering lets a human fix the action mid-run. Answering a clarifying question back to the user is the same mechanism, a pause resolved by an injected correction.

Open figure ↗

Fig 3 Pause, approve, resume, and steer, interactive. Run the refund task until it hits the approval gate and pauses with token appr-1 and an empty ledger because no money has moved, then resolve the pause three ways and watch the journal and ledger respond. APPROVE replays the journal, memoizes step 0 search_policy without re-running it, and executes the gated refund effectively-once via its idempotency key, finishing with the ledger at 180 dollars for ORD-3300. DENY records the decision and finishes with DENIED by approver, no money moved, and an empty ledger. STEER injects a correction that lowers the refund to 90 dollars under the 100 dollar threshold, applies it, and executes the corrected refund, finishing with the ledger at 90 dollars. The figure shows the interrupt journaling a token and raising a serializable PendingApproval rather than calling sys.exit, the resume replaying the same journal Part 9 built, and the decision being a first-class action that can approve, deny, or steer, with the idempotency key making an approved resume safe to retry.

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.00 refund over a $100 threshold) and correction of the agent mid-run, and both require the run to pause and hand control to a human.
  • interrupt() journals a pending_approval event with a token and raises a serializable PendingApproval caught at the top level, not sys.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-finished search_policy step, 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), or STEERED by approver -> amount lowered to $90.00 then refunded $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 PendingApproval and then calling resume on 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 a pending_approval event with a token and the pending action, and raising PendingApproval instead of calling the tool; it pauses the run and hands control out to the caller.
  • PendingApproval (serializable, not sys.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 not sys.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 the pending_approval journal event so the pause survives the process.
  • resume(run_id, decision): rehydrating a paused run by replaying its journal (Part 9’s fold), recording an approval_decision event, 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.

AgentsHuman-in-the-LoopApprovalsDurabilitySteeringAI