Home / Articles / Approval Gates in LangGraph.js: Pausing Agents with interrupt() and Command

This article is published in English.

Approval Gates in LangGraph.js: Pausing Agents with interrupt() and Command

Build a minimal LangGraph.js approval gate that pauses before a side effect, collects a human decision in the terminal, and resumes safely from a checkpoint.

3146 words

Some agent actions are too consequential to run unattended: sending an email on someone's behalf, deleting a record, approving a payment. For those, you want the agent to propose the action, stop, and wait for a person to say yes or no. This walkthrough builds that behavior in LangGraph.js with the smallest possible graph, so you can see exactly how interrupt(), a checkpointer, a thread_id and Command({ resume }) cooperate to pause a run and pick it up again later.

There is no multi-agent orchestration and no LLM call here, on purpose. The whole pattern fits in one sentence: pause, let a human decide, resume.

When an agent should not have the final word

Autonomy is valuable when you are comfortable letting the agent act on its own judgment. Plenty of actions do not meet that bar, and a human review step is worth the friction. Typical candidates include:

  • sending an email or chat message for a user
  • changing or removing a database record
  • approving a payment
  • deploying code
  • tearing down cloud resources
  • escalating a support ticket
  • publishing AI-generated content

In each case the goal is the same. The agent still does the thinking and prepares the action, but it presents its intent and hands the final decision to a person before anything irreversible happens. That arrangement is what Human-in-the-Loop (HITL) means in practice.

How interrupt() and Command fit together

Stripped to its essence, HITL in LangGraph works like this: the graph halts mid-execution, waits for input from outside, and then continues using that input.

The halting is done by interrupt(). When a node calls it, LangGraph stops the current run and saves the graph state through the configured checkpointer, so that the very same run can be continued later. Your application receives the value you passed to interrupt(), shows it to a human, collects an answer, and then resumes the graph by invoking it with a Command object that carries the answer.

The overall sequence looks like this:

Graph starts
    ↓
Agent decides to send email
    ↓
⏸ interrupt()
    ↓
Human reviews the action
    ↓
Approve / Reject
    ↓
Command({ resume: ... })
    ↓
Graph continues

Keep this shape in mind; every piece of code below maps to one arrow in it.

The scenario: an email that needs sign-off

The example uses an email. The agent decides it wants to send this message:

Meeting at 5 PM with Aman

Before that message goes anywhere, a person should review it and either approve or reject it. The branching looks like this:

User
  ↓
Agent decides to send email
  ↓
⏸ Human approval
  ↓
┌───────────────┐
│ Approve       │ → Send email
│ Reject        │ → Stop
└───────────────┘

To keep the focus on the pause-and-resume mechanics, no real email provider is involved. The node that "sends" the email just prints to the terminal. Swapping in a real API later changes nothing about the control flow.

Building the graph step by step

Install LangGraph and prepare the imports

Start from an empty Node.js project and add LangGraph:

npm install @langchain/langgraph

Depending on the version you install, LangGraph.js may also expect @langchain/core as a peer dependency; if npm warns about it or imports fail, add that package too and check the current installation docs.

Human input will come from the terminal through Node's built-in readline/promises module, so no extra package is needed for that. The imports pull in the graph builder, the state annotation helper, the START and END sentinels, interrupt, the in-memory checkpointer and Command, plus the readline pieces:

import {
  StateGraph,
  Annotation,
  START,
  END,
  interrupt,
  MemorySaver,
  Command,
} from "@langchain/langgraph";

import readline from "node:readline/promises";
import {
  stdin as input,
  stdout as output,
} from "node:process";

The file uses ES module syntax (import), so either name it with an .mjs extension or set "type": "module" in package.json, and use a Node version that supports top-level await, because the run code later awaits at module level.

Define the state the graph carries

State in LangGraph is a shared object that flows through the graph. Each node reads from it and returns partial updates. This graph needs just two fields:

  • message, the email text the agent proposes
  • decision, the answer the human gives
const StateAnnotation = Annotation.Root({
  message: Annotation,
  decision: Annotation,
});

Annotation.Root() declares the state's shape. With no reducer specified, each field simply takes the most recent value written to it. The agent node will fill in message; the approval node will fill in decision once the human has answered.

Write the action you want to protect

Next comes the node that represents the risky operation. In production this would call an email API. Here it only logs:

function sendEmail(state) {
  console.log(`\n📧 Email sent: "${state.message}"`);

  return {};
}

What this function does is almost irrelevant. What matters is when it runs: never before a human has approved. The rest of the graph exists to enforce that ordering. Notice that it returns an empty object, meaning it leaves the state untouched.

Stand in for the agent's decision

In a real system this is where an LLM would read the user's request and decide an email is needed, probably through tool calling. Adding a model here would only distract from the HITL mechanics, so a plain function plays the agent's role and returns the message it "chose":

function agent() {
  return {
    message: "Meeting at 5 PM with Aman",
  };
}

Read this node as the agent announcing its intent: this is the email it wants to send. If you later replace it with an LLM and tool-calling logic, the approval machinery around it stays essentially the same.

Pause for a human with interrupt()

This is the heart of the pattern. The approval node calls interrupt() with a payload describing what needs a decision, and returns whatever comes back as the new decision:

function humanApproval(state) {
  const decision = interrupt({
    message: state.message,
    question: "Do you want to send this email?",
  });

  return {
    decision,
  };
}

As soon as execution reaches the call

interrupt(...)

the graph halts. The object passed in becomes the interrupt payload that the calling application can read. In this case it is:

{
  message: "Meeting at 5 PM with Aman",
  question: "Do you want to send this email?"
}

The application shows that payload to a person, waits for an answer and resumes the graph. The key detail is that whatever value you supply on resume becomes the return value of interrupt(). So the line

const decision = interrupt(...);

effectively turns into the following once the human approves:

const decision = "approve";

That value is written to state as decision. A routing function then chooses the next step from it:

function routeAfterApproval(state) {
  if (state.decision === "approve") {
    return "sendEmail";
  }

  return END;
}

An "approve" answer leads to sendEmail; anything else ends the run. Treating every non-approval as a stop is a sensible default for a safety gate: if an unexpected value ever arrives, the graph fails closed rather than performing the action.

Add a checkpointer and wire the graph

One more ingredient is needed before compiling: a checkpointer. Because the run is going to stop and later continue, LangGraph has to persist the execution state at the moment of the interrupt. Without a checkpointer there is nothing to resume from. For a demo, the in-memory implementation is enough:

const checkpointer = new MemorySaver();

Now register the three nodes, connect START to the agent and the agent to the approval step, add a conditional edge from the approval step driven by routeAfterApproval, and compile with the checkpointer:

const graph = new StateGraph(StateAnnotation)
  .addNode("agent", agent)
  .addNode("humanApproval", humanApproval)
  .addNode("sendEmail", sendEmail)

  .addEdge(START, "agent")
  .addEdge("agent", "humanApproval")

  .addConditionalEdges(
    "humanApproval",
    routeAfterApproval,
    {
      sendEmail: "sendEmail",
      [END]: END,
    }
  )

  .compile({
    checkpointer,
  });

The third argument to addConditionalEdges maps each value the router can return to a destination node, which also lets LangGraph draw the graph correctly. The resulting topology:

START
  ↓
agent
  ↓
humanApproval
  ↓
 ┌──────────────┐
 │              │
approve       reject
 │              │
 ↓              ↓
sendEmail      END
 │
 ↓
END

MemorySaver holds checkpoints in process memory, which is ideal for experiments and useless once the process exits. For real deployments, use a persistent checkpointer backed by a database so that a paused run survives restarts and can be resumed from a different process or server, which is the normal situation when an approval arrives through a web UI hours later. For a closer look at how checkpoints are stored internally, see how LangGraph's in-memory saver organizes checkpoints and writes.

The other half of the persistence story is thread_id. It identifies which checkpointed run you are talking about. Pausing and resuming must use the same thread_id; otherwise LangGraph has no way to find the saved execution.

Running the flow from the terminal

Start the run and detect the interrupt

A readline interface turns the terminal into the human reviewer:

const rl = readline.createInterface({
  input,
  output,
});

The configuration object carries the thread_id under configurable. Everything that touches this run, both the initial call and the resume, must pass this same object (or at least the same id):

const config = {
  configurable: {
    thread_id: "thread-1",
  },
};

Start the graph with an initial state. The agent node will overwrite the empty message:

const stream = await graph.stream(
  {
    message: "",
  },
  config
);

Execution flows through agent into humanApproval, where interrupt() stops it. The stream then yields a chunk containing an __interrupt__ key. Its first entry's value is the payload passed to interrupt(), which the loop prints for the reviewer:

for await (const chunk of stream) {
  if (chunk.__interrupt__) {
    const interruptValue =
      chunk.__interrupt__[0].value;

    console.log(
      "\n⏸ Waiting for human approval...\n"
    );

    console.log(
      "The agent wants to send this email:"
    );

    console.log(`"${interruptValue.message}"`);

    console.log(
      `\n${interruptValue.question}`
    );
  }
}

The terminal output resembles the following. The first line represents the user's original request for context; the code shown above does not print it:

User: Send an email to Aman about the 5 PM meeting

⏸ Waiting for human approval...

The agent wants to send this email:
"Meeting at 5 PM with Aman"

Do you want to send this email?

At this moment the graph is paused and no email has been sent. The run is sitting in the checkpointer, waiting.

Ask for a decision and validate it

Now prompt the reviewer. The loop keeps asking until it gets one of the two accepted answers, normalizing whitespace and case first:

let humanAnswer;

while (true) {
  humanAnswer = (
    await rl.question("\nApprove or reject: ")
  )
    .trim()
    .toLowerCase();

  if (
    humanAnswer === "approve" ||
    humanAnswer === "reject"
  ) {
    break;
  }

  console.log(
    'Please type "approve" or "reject".'
  );
}

The terminal shows Approve or reject: and blocks. Typing approve breaks the loop, and the graph can be resumed. Validating input before resuming is worth the few extra lines: the value you pass back is exactly what your routing logic will see.

Resume with Command

Resuming means invoking the graph again, but instead of fresh input you pass a Command whose resume field holds the human's answer:

await graph.invoke(
  new Command({
    resume: humanAnswer,
  }),
  config
);

The same config is reused, which means the same thread_id, and that is how LangGraph locates the interrupted run. The resume value is delivered as the return value of interrupt(). With approve typed in, the call inside humanApproval

const decision = interrupt(...);

now yields approve. The node returns it as decision, and the router runs:

function routeAfterApproval(state) {
  if (state.decision === "approve") {
    return "sendEmail";
  }

  return END;
}

Because decision equals "approve", control moves to sendEmail, and the terminal prints:

📧 Email sent: "Meeting at 5 PM with Aman"

The email was "sent" only after explicit approval. When you are done, call rl.close() so the readline interface releases stdin and the process can exit.

What rejection looks like

Run the script again. Because MemorySaver lives in memory, a fresh process starts with an empty checkpoint store; if you rerun inside the same process, use a new thread_id so you are not resuming a run that already finished. When the prompt appears,

Approve or reject:

answer:

reject

The graph is resumed with that value. The snippet below spells out the literal for clarity; in the script it is simply humanAnswer:

await graph.invoke(
  new Command({
    resume: "reject",
  }),
  config
);

This time state.decision holds "reject", so the router returns END and sendEmail is never scheduled:

Agent wants to send email
        ↓
   ⏸ Paused
        ↓
Human: reject
        ↓
      END

The distinction matters. The graph does not merely produce a different message on rejection; the node that performs the side effect never executes at all. That is what makes the approval step a real safeguard rather than a cosmetic one.

The full picture

Putting everything together, the complete graph looks like this:

                 ┌─────────────┐
                 │    START    │
                 └──────┬──────┘
                        ↓
                 ┌─────────────┐
                 │    Agent    │
                 └──────┬──────┘
                        ↓
              ┌───────────────────┐
              │  Human Approval   │
              │                   │
              │   ⏸ interrupt()   │
              └─────────┬─────────┘
                        ↓
                 Human decides
                   /       \
                  /         \
             approve       reject
                ↓             ↓
         ┌────────────┐      END
         │ sendEmail  │
         └──────┬─────┘
                ↓
               END

In one line: the agent decides, the graph pauses, a human reviews, the graph resumes, and only then does the action run.

The re-execution gotcha: keep side effects after the interrupt

One behavior of interrupt() catches many people. On resume, LangGraph does not continue from the line after interrupt(). It re-runs the node that contains the interrupt from its first line. The difference on the second pass is that the interrupt() call returns the resume value immediately instead of pausing.

That has a direct consequence for side effects. Anything placed before interrupt() in the same node runs once when the graph pauses and again when it resumes. This is the pattern to avoid:

function humanApproval(state) {
  saveSomethingToDatabase();

  const decision = interrupt("Approve?");

  return { decision };
}

Here saveSomethingToDatabase() would execute twice for a single approval. The fix is structural: keep the approval node free of side effects, and put every real action in a later node that runs only after the human has answered. That is how the example is arranged:

humanApproval
      ↓
interrupt()
      ↓
human response
      ↓
sendEmail

If you truly must do work before an interrupt in the same node, make it idempotent (safe to repeat, for example an upsert keyed by a stable id) or move it into its own preceding node, whose completed result is already checkpointed and will not re-run.

What happens under the hood, in order

With the code out of the way, the lifecycle is short:

  1. The graph starts executing.
  2. The agent node decides it wants to send an email.
  3. Execution reaches interrupt().
  4. LangGraph stops the run.
  5. The current state is saved by the checkpointer.
  6. The application receives the interrupt payload.
  7. A person reviews the proposed action.
  8. That person gives a decision.
  9. The application resumes the graph with Command, using the same thread_id.
  10. interrupt() returns the person's answer.
  11. The graph continues along the branch that answer selects.

The API calls are the easy part; the pause-and-resume pattern is the idea worth internalizing:

        Graph
          │
          ▼
    Agent decision
          │
          ▼
      interrupt()
          │
          │
      ┌───┴───┐
      │ Human │
      └───┬───┘
          │
     approve/reject
          │
          ▼
       resume
          │
          ▼
      Continue

Once that flow is clear, HITL stops feeling mysterious. It is just a checkpointed pause with a typed answer at the end.

Checking your own implementation

Before relying on an approval gate, run through a few quick tests:

  • Approve once and confirm the action runs exactly once.
  • Reject and confirm the action node never executes, not just that the output differs.
  • Type an invalid answer and confirm the prompt repeats rather than resuming with garbage.
  • Resume with a different thread_id and observe that the original run is not affected.
  • With a persistent checkpointer, restart the process between the pause and the resume and confirm the run still completes.

Where the same gate applies

The email is just a convenient demonstration. The identical structure fits any action that needs human oversight: messaging, record updates or deletions, payment approvals, deployments, cloud resource teardown, publishing generated content, or escalating support requests. The action node changes; the gate in front of it does not. If you want to see approval steps alongside other orchestration patterns such as routing and fan-out, this overview of five LangGraph patterns puts them side by side.

Key takeaways

  • interrupt() pauses the graph and hands a payload to your application; the value you resume with becomes its return value.
  • Command({ resume: ... }) delivers the human's answer back into the paused run.
  • A checkpointer is mandatory for pausing; use a persistent one in production so approvals can arrive after restarts.
  • The same thread_id must be used to pause and resume a given run.
  • The node containing interrupt() re-runs from the top on resume, so keep side effects in later nodes or make them idempotent.
  • Route anything other than an explicit approval to a stop, so the gate fails closed.

You do not need a large workflow to put a human in control of an agent. One well-placed pause in front of the consequential step lets the agent do most of the work while a person keeps the final say.