← Back to Agentic AI map
Lesson 3.7 · Building with LangGraph

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.

langgraph

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.

workflowApprove before sending an emailstep 1 / 5

1 - The agent drafts

Process 1 invokes the graph on thread email-approval-123. The draft node asks llama3 for the email body.

thread_id
email-approval-123
recipient
john@example.com
draft time
2.4 s
sent
nothing yet

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.

Automatic or approved
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

Approve before sending - interrupt() and a router
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)
Output - three separate processes, real llama3 draft
$ 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)
What interrupt() did - checked
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 discarded
Approval in front of ToolNode
from 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())
Output - approve, edit and reject, each on its own thread
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

Pause

Stop inside a node and hand a payload to the app.

answer = interrupt({"body": state["body"]})
Detect the pause

invoke() returns an __interrupt__ list.

result["__interrupt__"][0].value
Is it waiting?

Pending interrupts on the thread.

graph.get_state(config).tasks[0].interrupts
Resume

interrupt() returns the answer this time.

graph.invoke(Command(resume=answer), config)
Route from the node

Choose the next node and update state at once.

return Command(goto="tools", update={...})
Static pause

Stop before a node; continue with None.

compile(checkpointer=cp, interrupt_before=["tools"])
Import

Both 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.

Count the re-run

“Put a print at the top of the approval node, pause and resume, and count how many times it prints.”

Reject

“Resume with {"decision": "reject"} and confirm send_email never runs.”

Two processes

“Run start in one terminal and resume edit in another. Then swap SqliteSaver for InMemorySaver.”

Bad answers

“Resume with "Approve", {}, and {"decision": "yes"}. Make every one of them end safely.”

Guard the tool

“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

Side effects before interrupt()

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(...)
Pausing without a durable checkpointer

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(...))
Trusting the browser

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)
Letting the answer change anything

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()
Executing twice

Double clicks and retries happen. Mark the approval used before resuming, and give the side effect an idempotency key.

Approving everything

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

What does Human-in-the-Loop mean?
A workflow where a person makes a decision at a defined point - typically approving, editing or rejecting a proposed action - before the graph continues.
What does interrupt() do?
Stops the run inside the node, returns its payload to the application, and leaves the run saved at that node. On resume it returns the human’s answer.
How do you resume, and what runs?
graph.invoke(Command(resume=answer), config) on the same thread. The paused node runs again from its start; earlier nodes do not.
Why is checkpointing needed?
Resuming requires the saved run - without a checkpointer Command(resume) raises - and approvals can wait long enough that only durable storage keeps them.
Should every action need approval?
No. Put approval before risky, external or irreversible actions; let low-risk work run automatically.

Quiz

  1. 1.

    The approval node logs "approval requested" before calling interrupt(). One approval later, how many log lines are there?

  2. 2.

    The reviewer clicks Approve and the client sends "Approve". The node checks decision == "approve". What happens?

  3. 3.

    What happens if you call Command(resume=...) on a graph compiled without a checkpointer?

  4. 4.

    A reviewer rejects a send_email tool call. How does the agent find out?

  5. 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...