Human-in-the-Loop and Production Deployment, Built Not Bulleted
Prerequisite: Lesson 06 (tool binding, side-effect awareness) and lesson 02 (state as a typed contract, the concept a checkpoint persists). After this lesson, you can: pause a graph at a specific node for human approval, resume it with that human's input, and explain exactly why a checkpointer isn't optional for any of this to work.
The bullet point this course opened with
Lesson 01's quickstart rebuild covered two working code examples and left one section of the original tutorial alone: a four-item "Deploy & Scale" checklist, ending with "consider human-in-the-loop: for critical decisions, incorporate human approval steps." Zero code accompanied that line in the original quickstart file — that part is a direct read of the source, confirmed. This course's other lessons cite roughly 42 source files across the full original tutorial series; this lesson's honest claim is narrower than "never mentioned once" across all of them, since that would require a full-text sweep of every file that wasn't run. What's actually verified: the specific quickstart section this lesson is closing the gap on names human-in-the-loop only as an unimplemented bullet point, with zero mechanism, and no other lesson in this course's own published material builds interrupt() or a checkpointer before this one does.
What actually has to happen for a pause to work
A LangGraph graph doesn't naturally support "stop here and wait for a person." Making that possible requires two things working together: interrupt(), which pauses execution inside a node, and a checkpointer, which persists the graph's full state to disk or a database at that pause point so the process can stop completely, not just block a thread, and resume later, possibly minutes or days after, possibly in an entirely different process.
from langgraph.types import interrupt, Command
from langgraph.checkpoint.memory import InMemorySaver
def request_approval(state: State) -> State:
decision = interrupt({
"action": "send_email",
"recipient": state["draft_recipient"],
"body": state["draft_body"],
})
if decision == "approved":
return {"status": "sending"}
return {"status": "cancelled"}
graph = builder.compile(checkpointer=InMemorySaver())Calling interrupt() inside request_approval pauses the graph at that exact point and surfaces the payload you passed it (here, the email that needs a human's sign-off) to whatever's watching the run. The graph fully stops. A checkpointer is not optional here; without one, there's no state to resume from once the pause has ended, and interrupt() has nothing to persist across.
Resuming later, potentially in a fresh process, looks like this:
result = graph.invoke(Command(resume="approved"), config={"configurable": {"thread_id": "run-42"}})Command(resume=...) is how a human's decision gets back into the graph. The thread_id in config is what ties this resume call to the exact paused run, which is also why a checkpointer that persists to more than memory (a real database, not InMemorySaver) is what makes this survive past a single process's lifetime, the actual requirement for "critical decisions get a human in the loop" to mean anything in production rather than in a notebook.
Deciding which nodes need this, not adding it everywhere
tools-memory-and-multi-agent-systems lesson 07 already named the framework for this decision: four side-effect tiers, Pure, Read, Write, Destructive, each with its own safety rule, and the Destructive tier's rule is explicit: human approval required. That's the actual design rule for where interrupt() belongs, cited directly rather than re-derived. A node that only reads (search_venues, in that lesson's own example) never needs a pause point; a node that does something irreversible (send_email, charge_card, delete_record) is exactly where the pattern above earns its cost.
Pure and Read nodes: no pause needed, ever. Write nodes: usually no pause, but idempotency matters more (a resumed run shouldn't double-send). Destructive nodes: this is where interrupt() belongs by default, not as an afterthought.
interrupt()'s payload is what a reviewer sees. Passing the bare fact that a decision is needed, with none of the content being decided on, forces the reviewer to go dig up context elsewhere. Pass the actual email draft, the actual charge amount, the actual record being deleted.
InMemorySaver is fine for local development and this lesson's own examples. It does not survive a process restart. Production human-in-the-loop needs a persistent backend (a database-backed checkpointer), because the whole point of the pattern is surviving the gap between "the agent needs a decision" and "a human got around to making it."
What this course adds up to
Eight lessons, from a deprecated import to a graph-native pause-and-resume mechanism the original quickstart named in one bullet point and never built. The thread running through all of them: the source material taught mechanisms in isolation, exercise by exercise, and this course kept asking the question that connects them, what decision does this mechanism actually make, and what does getting that decision wrong cost. State needs a reducer or it silently loses work. Tool descriptions need disambiguation or selection gets unreliable. Parallel execution has a graph-native path that avoids checkpointing conflicts. And human oversight is a design decision made per-node, using a framework you already have, not a bullet point added at the end.
Review the full course, or revisit any lesson: get-started through human-in-the-loop, all built against LangGraph's current API, not the one a 2025 tutorial happened to freeze in place.
Reply here and it goes straight to Rod. Same as replying to one of his emails.