DEV Community

Cover image for Human-in-the-Loop with LangGraph.js: Pausing a Graph for Approval
Gabriel Anhaia
Gabriel Anhaia

Posted on

Human-in-the-Loop with LangGraph.js: Pausing a Graph for Approval


An agent that can issue refunds needs a human between the decision
and the money. The naive implementation blocks a promise while
someone looks at a Slack message, which works for ninety seconds and
falls apart for anything longer — a deploy, a restart, or a user who
goes to lunch ends the run.

The requirement is a pause that survives the process. LangGraph.js
has one built in.

interrupt suspends the run

import { interrupt, Command } from "@langchain/langgraph";

async function approval(state: typeof State.State) {
  const decision = interrupt({
    kind: "refund_approval",
    orderId: state.orderId,
    amount: state.refundAmount,
    reason: state.refundReason,
  });

  return {
    approved: decision === "approve",
    messages: [
      { role: "user", content: `Human decision: ${String(decision)}` },
    ],
  };
}
Enter fullscreen mode Exit fullscreen mode

interrupt throws a special signal that the graph catches. Execution
stops at that node, state is checkpointed, and invoke returns to
the caller with an __interrupt__ payload. Nothing is blocked and
nothing is held in memory.

The argument is data you want the reviewer to see. Pass the fields a
UI needs to render the decision — order id, amount, reason — not a
prompt string, because something on the other end has to render it.

Reading the pause

const config = { configurable: { thread_id: `refund-${orderId}` } };
const result = await graph.invoke({ orderId, refundAmount }, config);

if (result.__interrupt__) {
  const [pending] = result.__interrupt__;
  await queue.enqueueReview({
    threadId: config.configurable.thread_id,
    payload: pending.value,
    interruptId: pending.id,
  });
  return { status: "awaiting_approval" };
}
Enter fullscreen mode Exit fullscreen mode

result.__interrupt__ is an array — a graph can pause in more than
one branch at once. The library also exports isInterrupted and an
INTERRUPT key for a typed check rather than reaching for the
property directly.

The thread id is the only thing you must persist. It is the pointer
back to the checkpoint, and it is what the approval UI sends back.
Deriving it from a domain id (refund-${orderId}) rather than a
random uuid means you can find a paused run without a lookup table.

Resuming, possibly days later

export async function applyDecision(
  threadId: string,
  decision: "approve" | "reject",
) {
  const config = { configurable: { thread_id: threadId } };
  const result = await graph.invoke(
    new Command({ resume: decision }),
    config,
  );
  return result;
}
Enter fullscreen mode Exit fullscreen mode

Command({ resume }) makes the original interrupt() call return
that value. The node continues from that line, in a different
process, on a different machine, after any interval.

The whole conversation, tool results, and accumulated state come back
from the checkpointer. Your HTTP handler for the approve button is
four lines.

A run pausing at interrupt, persisting, and resuming from another process via thread_id.

The gotcha that wastes an afternoon

There are two ways to call invoke on an existing thread and they
mean different things. Getting them backwards produces a graph that
appears stuck.

// resuming a pending interrupt — use Command
await graph.invoke(new Command({ resume: "approve" }), config);

// continuing a multi-turn conversation — use a plain object
await graph.invoke({ messages: [{ role: "user", content: "..." }] }, config);
Enter fullscreen mode Exit fullscreen mode

Command({ update }) for a follow-up turn is the wrong tool: it
resumes from the latest checkpoint — the last step that ran — rather
than restarting the graph from its entry point, and the symptom is a
run that returns immediately having done nothing.

Rule of thumb: Command({ resume }) answers a pending interrupt().
A plain object starts a new pass through the graph. Nothing else.

Where to put the interrupt

Placement decides whether the pause is any use.

const graph = new StateGraph(State)
  .addNode("assess", assess)
  .addNode("approval", approval)
  .addNode("issueRefund", issueRefund)
  .addEdge(START, "assess")
  .addConditionalEdges("assess", (s) =>
    s.refundAmount > 100 ? "approval" : "issueRefund",
  )
  .addConditionalEdges("approval", (s) =>
    s.approved ? "issueRefund" : END,
  )
  .addEdge("issueRefund", END)
  .compile({ checkpointer });
Enter fullscreen mode Exit fullscreen mode

Two things this gets right.

The interrupt is its own node, before the side effect. Calling
interrupt() inside issueRefund — after the refund API call — is a
pause that protects nothing.

It is conditional. Small refunds skip approval entirely. A
human-in-the-loop step that fires on every run is a queue nobody
services within a week.

The checkpointer requirement

This is where the pattern actually fails in production.

// works in tests, loses every pending approval on deploy
.compile({ checkpointer: new MemorySaver() })
Enter fullscreen mode Exit fullscreen mode

MemorySaver is in-process. A pause that must survive days needs a
durable checkpointer — Postgres or SQLite from the
@langchain/langgraph-checkpoint-* packages — and that store becomes
production data with real requirements:

Backed up, because a lost checkpoint is a refund stuck forever.
Migrated carefully, because a code change can make an old checkpoint
unresumable. Retained deliberately, because paused runs accumulate.

And one operational thing that has no technical fix: paused runs
need a timeout policy
. A refund awaiting approval for three months
is not paused, it is abandoned.

const stale = await db.query(
  `SELECT thread_id FROM checkpoints
   WHERE status = 'interrupted' AND updated_at < now() - interval '7 days'`,
);
for (const { thread_id } of stale.rows) {
  await applyDecision(thread_id, "reject");
  await notify(thread_id, "auto-rejected after 7 days");
}
Enter fullscreen mode Exit fullscreen mode

Resuming with a default decision is better than leaving state
forever. Which default is a product question — reject is the safe one
for money.

A conditional approval node placed before the side effect, with a staleness sweep for abandoned pauses.

Parallel interrupts

If two branches pause in the same superstep, you get two entries and
must answer both, keyed by interrupt id:

import { isInterrupted, INTERRUPT } from "@langchain/langgraph";

const resumeMap: Record<string, string> = {};
if (isInterrupted(result)) {
  for (const i of result[INTERRUPT]) {
    if (i.id) resumeMap[i.id] = await decisionFor(i.value);
  }
}
await graph.invoke(new Command({ resume: resumeMap }), config);
Enter fullscreen mode Exit fullscreen mode

A single scalar resume when two interrupts are pending answers one
and leaves the other hanging — which looks exactly like the graph
being stuck.

What this pattern is worth

The capability is not "ask a human a question". It is that a run can
be suspended to durable storage and resumed correctly by a different
process at an arbitrary later time.

That is genuinely hard to retrofit onto a loop, and it is the
strongest single argument for a graph framework. If your agent
touches money, sends things to customers, or changes infrastructure,
it is the argument that decides.


If this was useful

AI That Plans covers
human-in-the-loop end to end — interrupt placement, durable
checkpointers, approval UIs, timeout policy, and resuming safely when
the world has moved on since the pause.

AI That Plans — Stateful AI Agents with LangGraph.js

The full series is at
xgabriel.com/ai-in-typescript.

Top comments (0)