Human-in-the-Loop
Pause a graph with interrupt(), show a person the proposed action, and resume with their decision - approve, edit or reject - even from another process.
What you will be able to do
- Explain what Human-in-the-Loop adds to an agent, and where it belongs
- Pause a node with interrupt() and resume it with Command(resume=...)
- Route on the human’s answer: approve, edit or reject
- Resume a paused approval from a different process using a checkpointer
- Put an approval step in front of ToolNode
- Design the approval endpoint: authorization, idempotency, validation, audit
The idea, in plain English
Human-in-the-Loop (HITL) means a person is a step in an otherwise automated workflow. The agent researches, drafts and prepares; before something with real consequences happens - an email leaves, a refund is paid, a row is deleted - the graph stops and waits for a decision.
LangGraph does this with interrupt(). A node calls interrupt(payload); the run stops there, the payload goes back to your application, and the checkpointer from Lesson 3.6 saves the paused run under its thread_id. Later - seconds or days - your application calls graph.invoke(Command(resume=answer), config) on the same thread. The node runs again, and this time interrupt() returns the answer.
The answer is whatever you design: "approve", or a dict such as {"decision": "edit", "body": "..."}. A conditional edge (Lesson 3.3) then sends the run to the action or to END.
This is not the same as chatting. In a chat the user starts each turn; with HITL the workflow decides that a human is needed, at a point you defined, and nothing past that point runs until the answer arrives. Everything below was run on LangGraph 1.2.14 with llama3 drafting the email.
Worked example: Approve before sending an email: llama3 drafts, a reviewer edits, the graph sends.
1 - The agent drafts
Process 1 invokes the graph on thread email-approval-123. The draft node asks llama3 for the email body.
A real run with SqliteSaver: llama3 drafts in process 1, the graph pauses, a reviewer edits the draft and resumes it from process 2.
Where humans belong
Automate reasoning and low-risk work; put a person where a side effect is hard to undo or a decision needs judgment. Searching, summarizing, calculating and drafting run on their own. Sending, paying, deleting, deploying and changing permissions wait for approval.
Approval on every step destroys the point of an agent, and people asked to approve everything soon approve without reading. One meaningful checkpoint before the side effect beats five before trivia.
Research, search, summarizeAutomatic.Draft an email, a reply, a SQL queryAutomatic - the draft is what gets reviewed.Send email, post publiclyApproval: external communication.Refund, transfer, purchaseApproval: money moves.DELETE, deploy, change permissionsApproval: hard to undo.Low confidence, policy exceptionAsk a human to decide.interrupt() and Command(resume=...)
interrupt(value) takes anything JSON-serializable - here the recipient, subject and body. The first time the node runs, the call stops the run: invoke() returns the state so far plus an __interrupt__ list holding that value, and get_state(config).next is ('approval',). In stream mode the same thing arrives as an {"__interrupt__": ...} update.
To continue, invoke Command(resume=answer) with the same config. LangGraph runs the paused node again from the start; when execution reaches interrupt() it returns answer instead of pausing. Our approval node printed "started" twice for one approval, and the draft node before it was not re-run.
interrupt() needs a checkpointer to be useful. Without one, the first invoke still paused and returned __interrupt__, but Command(resume=...) raised "RuntimeError: Cannot use Command(resume=...) without checkpointer" - there was nothing saved to resume.
Watch out: Everything before interrupt() in the node runs twice: once to pause, once on resume. Keep that part free of side effects - do the drafting in an earlier node, and the sending in a later one.
Approve, edit, reject
Design the answer as a small schema, not free text. Ours was {"decision": "approve" | "edit" | "reject", "body": ...}. The node normalizes it - str(...).strip().lower() - and treats anything unrecognized as reject. That matters: with a plain decision == "approve" check, resuming with "Approve" counted as a rejection.
Edit lets the reviewer fix the proposal rather than throwing it away. The node returns the edited body with the approval; only the fields you allow can change. Never accept an arbitrary state update from the approval screen - a reviewer, or anyone who can call the endpoint, must not be able to set unrelated fields.
Paused for days: why the checkpoint matters
An approval can wait over lunch, overnight, or across a deploy. With SqliteSaver our draft-and-pause ran in process 1, and process 2 - a fresh Python process with the same graph and the same database file - found the run waiting at approval and resumed it. With InMemorySaver the pause would vanish with the process. In production that store is Postgres, so whichever worker handles the reviewer’s click sees the paused run.
Your application usually keeps its own table of pending approvals - thread_id, who must approve, what was proposed, when it expires - so a dashboard can list them without scanning checkpoints, and stale requests can be expired or escalated.
Approval in front of ToolNode
With a tool agent, the action is the tool call in the last AI message. Put a review node between the agent and ToolNode: it calls interrupt() with the tool name and arguments, then returns Command(goto="tools") to approve, Command(goto="tools", update=...) with an edited AIMessage (same message id, new arguments) to edit, or Command(goto="agent", update=...) with a ToolMessage saying it was rejected, so the model learns why.
We ran all three on a send_email tool. Approve sent "Deployment complete."; edit sent "Deployment of v2.5 complete."; reject sent nothing and the agent saw "Rejected by reviewer: wrong recipient". The drawn graph: agent -.-> review, review -.-> tools, review -.-> agent, tools --> agent.
For a fixed pause before a node, compile(interrupt_before=["tools"]) also works: the run stops before that node and invoke(None, config) continues. interrupt() inside a node is more flexible - it carries a payload and receives an answer.
Resuming safely
The thread_id selects the run - and LangGraph trusts it. Resuming a thread that did not exist did not raise a clear error: it started the graph from START with no input and failed with KeyError: 'recipient' inside a node. Resuming with new input instead of Command(resume=...) on a paused thread restarted the run and discarded the pending approval.
So the approval endpoint does the checking: authenticate the user, confirm they may approve this thread, confirm the thread is still waiting (get_state(config).tasks has an interrupt), validate the answer, then resume. A second approval of our finished thread found nothing pending and sent nothing - but two clicks arriving together can both pass a naive check, so mark the approval as used in your own database first, and make the side effect idempotent - an idempotency key on the email or refund.
Record an audit trail: thread, action, proposed payload, approver, time, decision, what was executed and its result. Approve what was shown: store the exact proposal with a version, and reject an approval that refers to an older one.
Step-by-step code
import sqlite3
import sys
from typing import TypedDict
from langchain_ollama import ChatOllama
from langgraph.checkpoint.sqlite import SqliteSaver
from langgraph.graph import END, START, StateGraph
from langgraph.types import Command, interrupt
llm = ChatOllama(model="llama3", temperature=0)
class EmailState(TypedDict, total=False):
request: str
recipient: str
subject: str
body: str
decision: str
def draft(state: EmailState):
print(" drafting with llama3")
reply = llm.invoke(
"Write a two-sentence email body (no greeting, no sign-off) for this request: "
+ state["request"]
)
return {"subject": "Deployment completed", "body": reply.content.strip()}
def approval(state: EmailState):
# Runs twice: once to pause, once on resume - no side effects here.
answer = interrupt({"to": state["recipient"], "subject": state["subject"], "body": state["body"]})
decision = str(answer.get("decision", "")).strip().lower()
if decision == "edit":
return {"decision": "approve", "body": answer["body"]}
return {"decision": decision if decision in ("approve", "reject") else "reject"}
def send_email(state: EmailState):
print(f" SENDING to {state['recipient']}: {state['body']}")
return {}
builder = StateGraph(EmailState)
builder.add_node("draft", draft)
builder.add_node("approval", approval)
builder.add_node("send_email", send_email)
builder.add_edge(START, "draft")
builder.add_edge("draft", "approval")
builder.add_conditional_edges("approval", lambda s: "send" if s["decision"] == "approve" else "stop",
{"send": "send_email", "stop": END})
builder.add_edge("send_email", END)
conn = sqlite3.connect("approvals.db", check_same_thread=False)
graph = builder.compile(checkpointer=SqliteSaver(conn))
config = {"configurable": {"thread_id": "email-approval-123"}}
if sys.argv[1] == "start":
result = graph.invoke({"request": "Tell John the v2.5 deployment finished",
"recipient": "john@example.com"}, config)
print(" waiting for:", result["__interrupt__"][0].value)
else:
state = graph.get_state(config)
pending = [i for task in state.tasks for i in task.interrupts]
if not pending:
print(" nothing waiting for approval on this thread")
sys.exit()
print(" waiting at:", state.next)
answer = {"decision": sys.argv[2]}
if sys.argv[2] == "edit":
answer["body"] = sys.argv[3]
result = graph.invoke(Command(resume=answer), config)
print(" decision:", result["decision"], "| next:", graph.get_state(config).next)$ python approve_email.py start
drafting with llama3
waiting for: {'to': 'john@example.com', 'subject': 'Deployment completed',
'body': 'Here is a two-sentence email body:\n\nThe v2.5 deployment has
successfully completed and is now live. All systems are functioning as
expected, and we are ready to move forward with the next steps.'}
$ python approve_email.py resume edit "The v2.5 deployment has completed and is live. All systems are working as expected."
waiting at: ('approval',)
SENDING to john@example.com: The v2.5 deployment has completed and is live. All systems are working as expected.
decision: approve | next: ()
$ python approve_email.py resume approve
nothing waiting for approval on this thread
On a fresh thread: resume reject -> decision: reject | next: () (nothing sent)First invoke
result keys: ['recipient', 'subject', 'body', '__interrupt__']
get_state(config).next: ('approval',)
stream_mode="updates": {'prepare': {...}} then {'__interrupt__': (Interrupt(value={...}),)}
One approval, counted
prepare ran 1 time, approval ran 2 times (pause + resume), send ran 1 time
Resume values
Command(resume="approve") -> approved True, email sent
Command(resume="reject") -> approved False, END
Command(resume="Approve") -> approved False (decision == "approve" is case-sensitive)
Edge cases
no checkpointer, invoke -> pauses, returns __interrupt__
no checkpointer, Command(resume=..) -> RuntimeError: Cannot use Command(resume=...) without checkpointer
resume a finished thread -> returns final state, nothing re-sent
resume an unknown thread_id -> runs from START with no input -> KeyError: 'recipient'
new input on a paused thread -> starts over, pending approval discardedfrom langchain_core.messages import AIMessage, ToolMessage
from langchain_core.tools import tool
from langgraph.checkpoint.memory import InMemorySaver
from langgraph.graph import END, START, MessagesState, StateGraph
from langgraph.prebuilt import ToolNode
from langgraph.types import Command, interrupt
OUTBOX = []
@tool
def send_email(to: str, body: str) -> str:
"""Send an email."""
OUTBOX.append((to, body))
return f"sent to {to}"
# agent: the Lesson 3.5 agent node - a model bound to [send_email]
def review(state: MessagesState):
request = state["messages"][-1]
call = request.tool_calls[0]
answer = interrupt({"tool": call["name"], "args": call["args"]})
if answer["decision"] == "approve":
return Command(goto="tools")
if answer["decision"] == "edit": # same message id -> replaces it
edited = AIMessage(content="", id=request.id,
tool_calls=[{**call, "args": {**call["args"], **answer["args"]}}])
return Command(goto="tools", update={"messages": [edited]})
rejected = ToolMessage(content=f"Rejected by reviewer: {answer.get('reason', '')}",
tool_call_id=call["id"])
return Command(goto="agent", update={"messages": [rejected]})
def needs_review(state: MessagesState):
return "review" if state["messages"][-1].tool_calls else END
builder = StateGraph(MessagesState)
builder.add_node("agent", agent)
builder.add_node("review", review, destinations=("tools", "agent"))
builder.add_node("tools", ToolNode([send_email]))
builder.add_edge(START, "agent")
builder.add_conditional_edges("agent", needs_review, ["review", END])
builder.add_edge("tools", "agent")
graph = builder.compile(checkpointer=InMemorySaver())paused with {'tool': 'send_email', 'args': {'to': 'john@example.com', 'body': 'Deployment complete.'}}
approve human -> ai send_email(...) -> tool 'sent to john@example.com' -> ai 'Done: sent to john@example.com'
outbox: ('john@example.com', 'Deployment complete.')
edit {"args": {"body": "Deployment of v2.5 complete."}}
outbox: ('john@example.com', 'Deployment of v2.5 complete.')
reject {"reason": "wrong recipient"}
tool 'Rejected by reviewer: wrong recipient' -> agent outbox: unchanged
graph: __start__ --> agent; agent -.-> review; agent -.-> __end__;
review -.-> tools; review -.-> agent; tools --> agent;Tip: Show the reviewer exactly what will execute - the final recipient, body and amount - and store that payload with the approval. People approve what they see, so what they see must be what runs.
Human-in-the-loop at a glance
PauseStop inside a node and hand a payload to the app.
answer = interrupt({"body": state["body"]})Detect the pauseinvoke() returns an __interrupt__ list.
result["__interrupt__"][0].value
Is it waiting?Pending interrupts on the thread.
graph.get_state(config).tasks[0].interrupts
Resumeinterrupt() returns the answer this time.
graph.invoke(Command(resume=answer), config)
Route from the nodeChoose the next node and update state at once.
return Command(goto="tools", update={...})Static pauseStop before a node; continue with None.
compile(checkpointer=cp, interrupt_before=["tools"])
ImportBoth live in langgraph.types.
from langgraph.types import interrupt, Command
Try it yourself
The code does not change. Swap the content string and the program does something else entirely.
“Put a print at the top of the approval node, pause and resume, and count how many times it prints.”
“Resume with {"decision": "reject"} and confirm send_email never runs.”
“Run start in one terminal and resume edit in another. Then swap SqliteSaver for InMemorySaver.”
“Resume with "Approve", {}, and {"decision": "yes"}. Make every one of them end safely.”
“Add the review node in front of ToolNode in your Lesson 3.5 graph, and reject one call with a reason.”
What usually goes wrong
The node re-runs from its first line on resume, so anything above interrupt() happens twice.
✗ def approval(state):
notify_slack(state["body"])
answer = interrupt(...)✓ def notify(state): notify_slack(...) # its own node
def approval(state): answer = interrupt(...)Without a checkpointer you cannot resume at all; with InMemorySaver the pause is lost on restart. Approvals wait for hours - use a database.
✗ builder.compile()✓ builder.compile(checkpointer=PostgresSaver(...))The client sends a request, not a fact. The backend authenticates, checks the user may approve this thread, checks it is still waiting, then resumes.
✗ if request.json["approved"]: send_email(...)✓ thread = approvals.get_pending(thread_id, approver=user.id)
graph.invoke(Command(resume=validated_answer), config)Accept a small schema - decision plus the fields that may be edited - and normalize it. Unknown means reject.
✗ return answer # whatever the client sent becomes state✓ decision = str(answer.get("decision", "")).strip().lower()Double clicks and retries happen. Mark the approval used before resuming, and give the side effect an idempotency key.
Approval on every search and calculation trains people to click without reading. Approve side effects, not reasoning.
Key points
- HITL puts a person at a defined point in the workflow, before actions with consequences.
- interrupt(payload) pauses the run; invoke() returns __interrupt__ with the payload.
- Command(resume=answer) on the same thread continues; interrupt() returns the answer.
- The paused node runs again from its start - nothing with side effects before interrupt().
- A checkpointer is required to resume; a database keeps the pause through restarts.
- Route on a validated answer: approve, edit or reject - unknown means reject.
- Approval is not authorization: the backend checks the user, the thread and its status.
- Make side effects idempotent and keep an audit trail of what was proposed and approved.
Quick check before you move on
Quiz
- 1.
The approval node logs "approval requested" before calling interrupt(). One approval later, how many log lines are there?
- 2.
The reviewer clicks Approve and the client sends "Approve". The node checks decision == "approve". What happens?
- 3.
What happens if you call Command(resume=...) on a graph compiled without a checkpointer?
- 4.
A reviewer rejects a send_email tool call. How does the agent find out?
- 5.
Which is safer: Browser -> execute tool, or Browser -> backend -> authorization -> LangGraph resume -> tool?
Interview questions
How would you implement human approval in a LangGraph agent?
Model approval as a node that calls interrupt() with the proposed action. Compile with a durable checkpointer so the paused run survives restarts. The application shows the payload to an authorized user, then resumes the same thread with Command(resume=decision); a conditional edge or Command(goto=...) routes to the action or to END. Around it: authorization, validation, idempotency, audit logging and expiry.
What should you watch for inside a node that calls interrupt()?
The node re-executes from the beginning on resume, so code before interrupt() runs twice. Keep side effects out of it and put them in their own nodes.
How do you let a reviewer edit an agent’s tool call?
Interrupt with the tool name and arguments, accept edited arguments in a fixed schema, and return an AIMessage with the same id and the new arguments - the messages reducer replaces it - then go to ToolNode.
How do you prevent duplicate execution after approval?
Record the approval in your own database and mark it used atomically before resuming, check the thread is still waiting, and pass an idempotency key to the side effect so a repeat is a no-op.
How would you secure the approval endpoint?
Authenticate the user, check they are allowed to approve that thread and tenant, verify the approval matches the exact proposal version shown, validate the answer schema, and log who decided what and when.
How do you handle approvals that stay pending for days?
Durable checkpoints keep the run; your own pending-approvals table tracks owner and deadline, sends reminders, and expires or escalates - resuming with a reject when the deadline passes.
Comments
Sign in to leave a comment. Your name and photo come from Google; nothing else is shared.
Loading comments...
AI
System Design
Backend
- GraphQL8 modules · 69 lessons planned
- Core Python13 modules · 75 lessons planned
- FastAPI5 sections · 20 lessons
- Node.js14 modules · 206 lessons planned
- Node.js Performance7 chapters · 36 topics
- Event Loop Lifecycle6 phases · 3 scenarios
- Docker & Containerization11 modules · 144 lessons planned
- AWS for Developers14 modules · 219 lessons planned
- CI/CD & DevOps Automation10 modules · 134 lessons planned