07 Oct 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
- interrupt() saves a checkpoint and stops the run
- 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
- Command(resume=value) reloads the checkpoint and continues
- 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
- Run the email example with “no” and confirm the status is cancelled
- Add an update={“status”: “approved”} to the send route using Command, and print the final state
- Build the “review and edit” node, resume with new text, and print the final draft
- 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:
- Generative AI Tutorial
- AI Ethics
- Machine Learning Tutorial
- Deep Learning Tutorial
- Ollama Tutorial
- Retrieval Augmented Generation (RAG) Tutorial
- ChatGPT Tutorial
- Microsoft Copilot Tutorial
No Comments