LangGraph human-in-the-loop in TypeScript: the interrupt pattern
Pause a LangGraph agent for human approval in TypeScript with the interrupt() pattern: interrupt, Command, checkpointers and thread_id, checked against the current LangGraph.js docs.
LangGraph human-in-the-loop in TypeScript: the interrupt pattern
Most agents should not delete a customer record, send an invoice, or merge a branch without a human saying yes first. The hard part is not deciding that you want approval. It is pausing a running graph mid-execution, surfacing the decision to a person, and then resuming exactly where you left off, sometimes minutes or hours later. This guide shows how to do that in TypeScript with LangGraph's interrupt() pattern, and every API claim here is checked against the current LangGraph.js docs.
Almost every tutorial for this is written in Python. The JavaScript and TypeScript API is close but not identical, and the small differences are exactly where people lose an afternoon. I run this pattern in production in an agent orchestrator, so this is the version I wish I had read first.
What human-in-the-loop actually requires
A blocking readline question holds the current process hostage and forgets everything if that process restarts. That is not human-in-the-loop, that is a hostage situation. Real approval has three requirements:
- The graph stops at the right place and emits the question to whatever surface your human uses (a web app, a Slack message, an email).
- The paused state is saved durably, so a deploy or a restart does not lose the run.
- When the human answers, the graph continues from the pause point with their answer, not from the beginning.
LangGraph gives you all three through one function and one checkpointer.
The interrupt() pattern in one minute
Inside any node you call interrupt(value). The value is whatever you want to show the human. On the first pass, interrupt() pauses the graph by throwing a special GraphInterrupt signal, and LangGraph persists the state. Later, you re-invoke the graph with a Command({ resume }), and that same interrupt() call returns the resume value as if it had been a normal function the whole time.
The signature, from the official reference, is interrupt<I, R>(value: I): R. One important rule: never catch the GraphInterrupt it throws. A try/finally for cleanup is fine, but a catch that swallows the signal breaks the pause.
One thing the diagram makes obvious and most tutorials skip: on resume, the whole node re-runs from the top, not from the interrupt() line. Keep any code above the interrupt() call idempotent, or you will send the same email twice the day a human clicks approve.

A minimal example in TypeScript
Here is a complete approval gate. Every symbol below exists in the current @langchain/langgraph package.
import {
StateGraph,
Annotation,
MemorySaver,
interrupt,
Command,
START,
END,
} from "@langchain/langgraph";
// 1. State
const StateAnnotation = Annotation.Root({
input: Annotation<string>(),
approved: Annotation<boolean>(),
});
// 2. A node that pauses for a human decision
async function approvalNode(state: typeof StateAnnotation.State) {
// First pass: throws GraphInterrupt and pauses.
// After resume: returns the value you passed to Command({ resume }).
const approved = interrupt<string, boolean>(
`Approve processing of: ${state.input}?`,
);
return { approved };
}
// 3. Compile WITH a checkpointer (required for interrupt)
const checkpointer = new MemorySaver();
const graph = new StateGraph(StateAnnotation)
.addNode("approval", approvalNode)
.addEdge(START, "approval")
.addEdge("approval", END)
.compile({ checkpointer });
Running it is two calls. The first pauses, the second resumes.
// thread_id ties this run to its saved state
const config = { configurable: { thread_id: "thread-1" } };
// First invoke: stops at interrupt()
const paused = await graph.invoke({ input: "delete 1,204 records" }, config);
// __interrupt__ is an array of Interrupt objects, so the value you
// passed to interrupt() is at paused.__interrupt__[0].value
// ... show the question, collect a yes/no, then resume ...
// Same thread_id, plus the human's answer
const final = await graph.invoke(new Command({ resume: true }), config);
console.log(final.approved); // true
That is the whole pattern. The agent stops, you get a question, the human answers, the agent continues.
How resuming actually works
Two things make the resume work, and both are easy to get wrong.
First, the thread_id. You pass it as config.configurable.thread_id on the initial invoke and on the resume, and it has to be the same value both times. The checkpointer uses it to find the paused run. A different thread_id means a different conversation, and the graph quietly starts over. If your resume seems to ignore the human's answer, this is almost always why.
Second, the resume payload. new Command({ resume: <value> }) is passed as the input to .invoke() or .stream(), not as an option. Whatever you put in resume becomes the return value of the original interrupt() call. It can be a boolean, a string, or a full object if you want the human to edit the agent's proposal rather than just approve it.
MemorySaver is for development, not production
interrupt() does nothing without a checkpointer, because there is no state to come back to. MemorySaver is the in-memory option and it is perfect for tests and local runs. It also disappears the moment your process restarts, which makes it the wrong choice for anything real.
For production you want a durable checkpointer such as the Postgres saver from @langchain/langgraph-checkpoint-postgres. The API surface is the same, so you can develop against MemorySaver and swap it at deploy time. State that survives a restart is the entire point of human-in-the-loop, because humans take coffee breaks and deploys happen mid-approval. (If the idea of a process whose memory survives its own restarts interests you beyond the code, I wrote about it from the inside in If I were continuous.)
Static breakpoints versus dynamic interrupt
LangGraph also has static breakpoints: interruptBefore and interruptAfter, set when you compile the graph. They pause before or after a named node. They still exist, but the docs steer you toward dynamic interrupt() for human-in-the-loop and keep static breakpoints for debugging. The reason is flexibility. Dynamic interrupt() can live anywhere in your node logic and fire conditionally, for example only when an amount crosses a threshold. Static breakpoints fire every time, no matter the context. Use interrupt() for approvals and keep static breakpoints for debugging.
Reading the interrupt as it happens
.invoke() returns the interrupt on the final state, which is enough for a simple request/response flow. If you want the pause as a live event, for example to push the question to a UI the moment it fires, stream with streamMode: "updates" and watch for the __interrupt__ key:
for await (const chunk of await graph.stream(
{ input: "delete 1,204 records" },
{ ...config, streamMode: "updates" },
)) {
if ("__interrupt__" in chunk) {
const [pause] = chunk.__interrupt__;
console.log("human needed:", pause.value);
}
}
This streaming behaviour, and some early confusion around it, is tracked in langgraphjs issue #1422. If your interrupt seems to disappear with .invoke(), streaming is the first thing to try.
Wrapping up
Human-in-the-loop in LangGraph.js comes down to four moving parts: interrupt() to pause, a checkpointer to remember, a stable thread_id to find the run again, and Command({ resume }) to continue. Get those right and your agent will wait politely for a human instead of charging ahead.