Human-in-the-loop approval with LangGraph

· checked against the LangGraph docs · 6 min read

In LangGraph, a node calls interrupt(payload) and the run stops at that line, saved by the checkpointer. On the client, useStream from @langchain/react exposes the payload as stream.interrupt. Render it as an approval card, then resume with stream.respond(decision). The value you resume with becomes the return value of interrupt(), so the node reads the decision and carries on.

How LangGraph pauses for a person#

LangGraph's Interrupts page lists three things an interrupt needs: a checkpointer to save state, a thread_id so the run knows which state to load, and a call to interrupt() with a JSON-serializable payload. The pause is durable. A person can close the tab, come back the next morning on another device, and the run resumes from the same line.

Two details shape the UI:

  • The node runs again on resume. LangGraph restarts the node from the top and interrupt() returns the resume value the second time. The docs' rules: put side effects after the interrupt() call or in a later node, make anything before it idempotent, and never wrap interrupt() in try/catch.
  • The payload is yours. LangGraph does not prescribe its shape. Send what the card needs to show (the action, the numbers, the risk), not the whole state.

On the client, useStream (from @langchain/react, the v1 frontend SDK) keeps the interrupt with the thread state and resumes it with stream.respond(value). The frontend Human-in-the-Loop page covers createAgent with humanInTheLoopMiddleware, which sends a fixed HITLRequest shape with approve, reject, edit and respond. This guide uses a plain graph and its own payload; the card works with either.

From interrupt to card#

LangGraph moment What the person sees Approval card props
Run streaming, no interrupt The run's progress Not rendered, or a run timeline
stream.interrupt set The card with Approve and Reject status="pending", onApprove, onReject
respond in flight The buttons hold, the card says Approving Return the respond promise
Resumed, decision in state A one-line receipt status from the stored decision
Page reloaded while paused The same card, still pending useStream reloads the thread's interrupt

The code#

The graph drafts a campaign, pauses in review with the numbers a person needs, and stores the decision in state so the receipt survives a reload.

agent/graph.ts
import { Annotation, END, MemorySaver, START, StateGraph, interrupt } from "@langchain/langgraph";

export type ReviewRequest = { action: string; audience: string; recipients: number; sender: string };
export type ReviewDecision = { approved: boolean; note?: string };

const State = Annotation.Root({
  request: Annotation<ReviewRequest | undefined>(),
  decision: Annotation<ReviewDecision | undefined>(),
  sentAt: Annotation<string | undefined>(),
});

const draft = async () => ({
  request: { action: "Send the renewal reminder", audience: "Annual plans renewing in 14 days", recipients: 1240, sender: "billing@northwind.dev" },
  decision: undefined,
});

// Everything above interrupt() runs again on resume, so nothing here sends anything.
const review = (state: typeof State.State) => {
  const decision = interrupt<ReviewRequest, ReviewDecision>(state.request!);
  return { decision };
};

const send = async () => ({ sentAt: new Date().toISOString() });

export const graph = new StateGraph(State)
  .addNode("draft", draft)
  .addNode("review", review)
  .addNode("send", send)
  .addEdge(START, "draft")
  .addEdge("draft", "review")
  .addConditionalEdges("review", (state) => (state.decision?.approved ? "send" : END), ["send", END])
  .addEdge("send", END)
  .compile({ checkpointer: new MemorySaver() });

The client reads the pending interrupt for the buttons and the stored decision for the receipt.

components/campaign-review.tsx
"use client";

import { useStream } from "@langchain/react";
import { ApprovalCard } from "@/components/pixilui/approval-card";
import type { ReviewDecision, ReviewRequest, graph } from "../agent/graph";

export function CampaignReview() {
  const stream = useStream<typeof graph, ReviewRequest>({ apiUrl: "http://localhost:2024", assistantId: "campaign" });
  const request = stream.interrupt?.value ?? stream.values.request;
  if (!request) return null;

  const decision = stream.values.decision;
  const resume = (value: ReviewDecision) => stream.respond(value);

  return (
    <ApprovalCard
      title={`${request.action} to ${request.recipients.toLocaleString("en-US")} customers`}
      risk={request.recipients > 1000 ? "high" : "medium"}
      items={[
        { label: "Audience", value: request.audience },
        { label: "Recipients", value: request.recipients.toLocaleString("en-US"), numeric: true },
        { label: "From", value: request.sender },
      ]}
      source="Campaign agent"
      status={!decision ? "pending" : decision.approved ? "approved" : "rejected"}
      receipt={decision?.note ? `Held back: ${decision.note}` : undefined}
      labels={{ approve: "Approve and send" }}
      onApprove={() => resume({ approved: true })}
      onReject={(note) => resume({ approved: false, note: note || undefined })}
    />
  );
}

Install the card with npx shadcn@latest add @pixilui/approval-card. In production, swap MemorySaver for a database checkpointer; the Interrupts page asks for a durable one.

What the card adds to the interrupt#

LangGraph gives you a durable pause and a payload; what the person sees is up to you. The card covers:

  • A second press for high risk. 1,240 emails is risk="high", so Approve asks once more before it resumes.
  • A note on reject. The note goes into the resume value, and the graph can route back to the draft with it.
  • Pending and errors. respond returns a promise; the card holds its buttons until it settles and offers them again if it throws.
  • A receipt from state. Because the decision lives in graph state, the folded receipt is there after a reload, not only in this tab.

When the framework already ships a UI#

LangGraph's docs list no approval component of their own. The frontend pages build approval cards from plain markup and name AI Elements, assistant-ui, CopilotKit and OpenUI as libraries that can own the presentation layer on top of useStream. If one of those already renders your chat, its approval UI is a fair choice; the card here is for when the approval sits in your own screen, with risk levels and a receipt.

When not to use this#

  • The interrupt asks a question, not for consent. "Which workspace?" is a form or a choice, not Approve and Reject.
  • Several interrupts at once. Parallel branches can pause together; LangGraph resumes them with a map of interrupt IDs. Show a list of cards and one submit, or an inbox.
  • No checkpointer. Without one there is nothing to resume; the docs list it as a requirement.

Questions#

What does interrupt() return in LangGraph?

The value you pass to Command({ resume }). The first time the node runs, interrupt() pauses the graph; when you resume, LangGraph runs the node again from the top and interrupt() returns your resume value.

How do I resume a LangGraph interrupt from React?

Call stream.respond(value) on the handle from useStream; it sends value as the resume of the pending interrupt, and stream.interrupt resets once the run picks up again. The frontend docs also show stream.submit(null, { command: { resume } }), but the @langchain/react 1.2.9 types we checked on 4 November 2026 have no command option on submit, while respond is documented in the package itself. For several interrupts at once, respondAll takes a map of interrupt IDs.

Is a LangGraph interrupt safe across page reloads?

Yes, with a durable checkpointer and the same thread_id. The state is saved at the interrupt, and useStream reloads the thread, so the card comes back pending.

Should I use humanInTheLoopMiddleware or my own interrupt?

Use the middleware with createAgent when approve, reject, edit and respond on tool calls is what you need. Write your own interrupt() when the decision is about something other than a single tool call, like a whole campaign.

Sources#