LangGraph Human-in-the-Loop: Pause, Approve, and Resume

Some actions shouldn’t happen automatically: sending an email, deleting data, spending money. Human-in-the-loop lets a graph pause, wait for a person’s decision, and then resume exactly where it stopped.

The three ingredients

  • interrupt(): called inside a node to pause the graph and show a value to the human
  • A checkpointer plus a thread_id: saves the paused state (Chapter 13). This is required, or there is nothing to resume from.
  • Command(resume=…): sent later to continue, carrying the human’s answer

Example: approve before sending an email

We will simulate the email step, so no real message is sent.

from typing import Literal, TypedDict
from langgraph.graph import StateGraph, START, END
from langgraph.checkpoint.memory import InMemorySaver
from langgraph.types import interrupt, Command

class State(TypedDict):
    draft: str
    status: str

def write_draft(state: State):
    return {"draft": "Hi team, the meeting is moved to 4 PM."}

def approval(state: State) -> Command[Literal["send", "cancel"]]:
    decision = interrupt({
        "question": "Send this email?",
        "draft": state["draft"],
    })
    if decision == "yes":
        return Command(goto="send")
    return Command(goto="cancel")

def send(state: State):
    return {"status": "sent"}

def cancel(state: State):
    return {"status": "cancelled"}

builder = StateGraph(State)
builder.add_node("write_draft", write_draft)
builder.add_node("approval", approval)
builder.add_node("send", send)
builder.add_node("cancel", cancel)

builder.add_edge(START, "write_draft")
builder.add_edge("write_draft", "approval")
builder.add_edge("send", END)
builder.add_edge("cancel", END)

graph = builder.compile(checkpointer=InMemorySaver())

Step 1: Run until the pause

config = {"configurable": {"thread_id": "email-1"}}

result = graph.invoke({"draft": "", "status": ""}, config)

print(result["__interrupt__"][0].value)
print(graph.get_state(config).next)

Output:

{'question': 'Send this email?', 'draft': 'Hi team, the meeting is moved to 4 PM.'}
('approval',)

The graph ran write_draft, reached interrupt(), and stopped. The special __interrupt__ key in the result holds what the human needs to see, and get_state(config).next confirms the graph is waiting at the approval node.

Step 2: Resume with the human’s answer

final = graph.invoke(Command(resume="yes"), config)
print(final["status"])

Output:

sent

The value passed to resume becomes the return value of interrupt(), so decision is “yes” and the graph routes to send. Resume with “no” instead, and it routes to cancel.

Note that we passed the same config (same thread_id). That is how LangGraph finds the paused run.

How it works

  1. interrupt() saves a checkpoint and stops the run
  2. Your app shows the question to a person, and this can happen minutes or days later, even after a restart if you use a database checkpointer
  3. Command(resume=value) reloads the checkpoint and continues
  4. interrupt() now returns value, and the node finishes

The Command object

You have now seen Command do two jobs:

  • Command(resume=…): sent into the graph, to answer an interrupt
  • Command(goto=”node”): returned from a node, to choose the next node (an alternative to conditional edges from Chapter 6)

A node that returns Command(goto=…) can also update state at the same time with Command(goto=”send”, update={“status”: “approved”}).

Letting the human edit

The resume value can be anything, not just yes or no. Here the human can rewrite the draft:

def review(state: State):
    edited = interrupt({"instruction": "Edit the draft if needed", "draft": state["draft"]})
    return {"draft": edited}

Resume with the corrected text:

graph.invoke(Command(resume="Hi team, the meeting is now at 5 PM."), config)

The node stores the human’s version as the new draft. This “review and edit” pattern is common in AI writing tools.

Golden rule: the node restarts from the top

When you resume, LangGraph re-runs the paused node from its first line. interrupt() then returns the answer instead of pausing. Two consequences:

  • Anything before interrupt() in that node runs twice
  • So never put side effects (sending emails, charging cards, writing to a database) before the interrupt() call

Good:

def approval(state: State):
    decision = interrupt("Send this?")      # pause first
    if decision == "yes":
        send_real_email(state["draft"])     # side effect only after approval

Bad:

def approval(state: State):
    send_real_email(state["draft"])         # would run again on resume
    decision = interrupt("Send this?")

Other useful patterns

  • Approve a tool call: put an approval node between the agent and its tool node from Chapter 11, so risky tools (delete, pay, send) need a human’s OK. For agents built with create_agent, LangChain also provides ready-made human-in-the-loop middleware, which you can explore after you are comfortable with interrupt().
  • Several questions in one node: you can call interrupt() more than once, and each resume answers the next one in order
  • Breakpoints for debugging: compile(interrupt_before=[“send”]) pauses before a node without any code inside it. It is handy while developing, but interrupt() is better for real approvals because it can show data and receive a response.

Security notes

  • Check who is approving: verify on the server that the person resuming is allowed to approve that action
  • Show exactly what will happen: display the real draft or arguments, not just “Approve?”
  • Expect stale approvals: if a paused run sits for days, the situation may have changed
  • Use a persistent checkpointer in production: with InMemorySaver, a restart loses all paused runs

Common mistakes

  • No checkpointer: interrupt() raises an error, because there is nowhere to save the pause
  • A different thread_id on resume: LangGraph can’t find the paused run
  • Passing plain input to resume: use Command(resume=…), not a normal state dictionary
  • Side effects before interrupt(): they will run twice
  • Forgetting the __interrupt__ key is only in the result while paused: after resuming to completion, it is gone
  • Expecting interrupt() to return immediately: it stops the run, and the code after it runs only after Command(resume=…)

Summary

  • interrupt() pauses a graph, and Command(resume=…) continues it
  • It needs a checkpointer and the same thread_id both times
  • The resume value becomes the return value of interrupt()
  • The paused node restarts from the top, so keep side effects after the interrupt
  • Use it for approvals, edits, and any action you would want a person to double-check

Practice

  1. Run the email example with “no” and confirm the status is cancelled
  2. Add an update={“status”: “approved”} to the send route using Command, and print the final state
  3. Build the “review and edit” node, resume with new text, and print the final draft
  4. Print graph.get_state(config).next before and after resuming, and compare

If you liked the tutorial, spread the word and share the link and our website, Studyopedia, with others.


For Videos, Join Our YouTube Channel: Join Now


Read More:

LangGraph Threads: Handle Multiple Conversations and Users
LangGraph Streaming: Stream Responses and Events
Studyopedia Editorial Staff
contact@studyopedia.com

We work to create programming tutorials for all.

No Comments

Post A Comment