tl;dr. interrupt() pauses a run mid-node
and checkpoints everything; a Command with
resume continues it — minutes or days later, in a different
process. that makes human approval a first-class node in the graph
instead of a slack message and a prayer. if you take one thing from this
course into production, take this lesson.
the pause button
everything so far ran start to finish. real agent systems can't: some actions are too expensive to be wrong about — merging code, sending a letter to a debtor, deleting anything. the naive fix is to have the agent ask in chat, which works until the run is unattended, which is the whole point of a team. the structural fix is a gate: a node where the graph stops, durably, until a human answers.
import { interrupt, Command, MemorySaver } from "@langchain/langgraph";
// a gate is a node that pauses the run and waits for a human.
async function approvalGate(state: typeof PipelineState.State) {
// interrupt() STOPS here. state is checkpointed; the run ends.
// whatever you pass out is what the approver sees.
const decision = interrupt({
question: "ship this change?",
summary: state.reviewSummary,
diffUrl: state.prUrl,
});
// ...and when resumed, execution continues HERE, with the
// human's answer as the return value.
if (decision.approved) return { status: "approved" };
return { status: "rejected", notes: [decision.reason] };
}
const graph = builder.compile({
checkpointer: new MemorySaver(), // postgres in production
}); - the checkpointer is the enabler.
interrupt()without a checkpointer is an error — there'd be nothing to resume. the checkpointer snapshots state at every superstep; the pause just makes that persistence visible.MemorySaverfor dev, the postgres saver for anything that must survive a deploy. - what you pass out is the approval ui. the interrupt payload is what your approver sees — a question, a summary, a link to the diff. design it like a pr description: enough to decide, not enough to drown. an approver who has to reconstruct context will start rubber-stamping, and then the gate is theatre.
- the thread is the unit of work.
thread_idnames the run — one per ticket, issue, or case. a paused thread costs nothing while it waits. a hundred paused threads are a review queue, which is exactly how you should ui them.
resume is a second invoke
// day 1, 09:14 — the run hits the gate and stops.
const config = { configurable: { thread_id: "issue-482" } };
await graph.invoke(input, config);
// graph state: paused at approvalGate. process can exit.
// day 2, 21:37 — you open the laptop and approve.
// same thread_id is the whole trick: the checkpointer
// rehydrates the run exactly where it stopped.
await graph.invoke(
new Command({ resume: { approved: true } }),
config,
);
// approvalGate's interrupt() returns { approved: true },
// the node finishes, the graph continues to the next edge. the part that feels like a trick until you trust it: nothing was running in between. no process waited, no websocket held. the run is rows in a checkpoint table, and the resume call rehydrates it. this is what makes the evening-review routine possible at all — agents work in the morning, gates accumulate during the day, and the human clears the queue at night. the pause is load-bearing for the whole operating model, not a safety afterthought.
gates change time, and time changes design
a graph with human gates runs on human time — hours and days, not seconds. that has consequences. state must be resume-legible: a node that stashed context in a local variable instead of state loses it at the pause, so the annotation is the only memory you get to keep. the world moves while you wait — the pr the gate summarized on monday may not match main by wednesday, so re-validate staleness after the gate for anything that waited long. and rejection needs a path, not an exception: route it back into the pipeline with notes, the way a reviewer sends back a pr. approve-or-crash is not a review process.
when it breaks
- side effects before the pause.
on resume, langgraph re-runs the interrupted node from its
top, replaying the recorded interrupt value. code before
interrupt()executes again — an api call there fires twice. keep gate nodes pure: read state, interrupt, act on the answer. side effects live in the next node. - the swallowed queue. paused threads don't page anyone by themselves. without a surface that shows "four runs waiting, oldest is three days" — a dashboard, a digest, anything — gates become the place work goes to die quietly.
- memory checkpoints in production.
MemorySaverplus a redeploy equals every paused run gone, silently. if a human is expected to answer tomorrow, the checkpoint must live in a database today. this one is embarrassing precisely because it fails weeks after it was written.
all the pieces exist now: agents, teams, fan-out, and a pause button. the capstone puts them to work: the sdlc as a graph.