# Human-in-the-loop approval with LangGraph

> Pause a LangGraph run with interrupt(), show the payload as an approval card through useStream, and resume with Command({ resume }). Code checked against LangGraph.js.

Published 2026-11-04. Facts checked 2026-11-04. Source: https://pixilui.com/guides/human-approval-langgraph

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](https://docs.langchain.com/oss/javascript/langgraph/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](https://docs.langchain.com/oss/javascript/langchain/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.

```ts
// 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.

```tsx
// 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.

## Related

- [Human-in-the-loop approval for AI agents](/guides/human-in-the-loop-approval-for-ai-agents): what a good approval step shows.
- [Agent run timeline with LangGraph](/guides/agent-run-timeline-langgraph): the same run, step by step, with the interrupt as a waiting step.
- [Human approval with the Vercel AI SDK](/guides/human-approval-vercel-ai-sdk): the same card on `toolApproval`.
- [Human approval with the OpenAI Agents SDK](/guides/human-approval-openai-agents-sdk): the same card on `needsApproval`.

## Sources

- [LangGraph: Interrupts](https://docs.langchain.com/oss/javascript/langgraph/interrupts), read 4 November 2026
- [LangChain frontend: Human-in-the-Loop](https://docs.langchain.com/oss/javascript/langchain/frontend/human-in-the-loop), read 4 November 2026
- [LangChain frontend: Overview](https://docs.langchain.com/oss/javascript/langchain/frontend/overview), read 4 November 2026
- [@langchain/react on npm](https://www.npmjs.com/package/@langchain/react) (version 1.2.9, its `useStream` types), read 4 November 2026
