Human-in-the-Loop Patterns

The agents from Chapters “Building a ReAct Agent with LangGraph + Ollama” and “Durable, Restart-Safe Agents” run to completion without ever pausing to check in with a human. That is exactly what you want for most background tasks and most read-only assistants. It is exactly what you do not want any time an agent is about to take a consequential action (send an email, make a purchase, publish a post, execute a shell command) or produce output that a human needs to see and possibly correct before anything downstream consumes it.

This chapter covers three LangGraph mechanisms for putting a human in the loop:

  • interrupt(): called from inside a node, halts the graph and returns control to the caller with a payload. The caller decides what to do, then resumes with Command(resume=value). The value shows up as the return of interrupt(), and the node continues.
  • interrupt_before=[...] and interrupt_after=[...]: compile-time arguments that tell the graph to pause before or after specific nodes without any special code in the node bodies.
  • agent.update_state(config, values): modify the recorded state of a paused graph before resuming. Combined with interrupt_after, this is the pattern for “let the human see and edit what the graph produced.”

Everything in this chapter assumes a checkpointer. Calling interrupt() without a checkpointer would result in a crash: the graph would have nowhere to save the paused state to. All three examples use MemorySaver because the human lives in the same Python process, but every mechanism works identically with SqliteSaver or PostgresSaver, and in a real web-app deployment that is what you would usually use.

Example 1: the minimum viable interrupt

Our first example is source-code/langgraph_hitl/01_interrupt_basic.py:

 1 from operator import add
 2 from typing import Annotated, TypedDict
 3 
 4 from langgraph.checkpoint.memory import MemorySaver
 5 from langgraph.graph import END, START, StateGraph
 6 from langgraph.types import Command, interrupt
 7 
 8 
 9 class State(TypedDict):
10     log: Annotated[list[str], add]
11 
12 
13 def step_one(state: State) -> dict:
14     return {"log": ["step one done"]}
15 
16 
17 def step_two(state: State) -> dict:
18     answer = interrupt({"question": "What should I record next?"})
19     return {"log": [f"human said: {answer}"]}
20 
21 
22 def step_three(state: State) -> dict:
23     return {"log": ["step three done"]}
24 
25 
26 graph = StateGraph(State)
27 graph.add_node("one", step_one)
28 graph.add_node("two", step_two)
29 graph.add_node("three", step_three)
30 graph.add_edge(START, "one")
31 graph.add_edge("one", "two")
32 graph.add_edge("two", "three")
33 graph.add_edge("three", END)
34 
35 app = graph.compile(checkpointer=MemorySaver())
36 config = {"configurable": {"thread_id": "1"}}
37 
38 first = app.invoke({"log": []}, config=config)
39 
40 print("=== State after first invoke (paused at interrupt) ===")
41 print(f"log so far: {first.get('log')}")
42 print(f"interrupt payload: {first.get('__interrupt__')}")
43 
44 final = app.invoke(Command(resume="hello from the human"), config=config)
45 
46 print("\n=== Final state after resume ===")
47 for line in final["log"]:
48     print(f"  {line}")

Three-node linear graph. Nothing special until the middle node calls interrupt({"question": "..."}) in line 18 of the code listing. The graph engine catches that call, records the payload as the interrupt info on the current state, and returns from .invoke() with a new state that in this simple example is changed by adding the human response to the log.

Here is example output:

1 $ uv run 01_interrupt_basic.py
2 === State after first invoke (paused at interrupt) ===
3 log so far: ['step one done']
4 interrupt payload: [Interrupt(value={'question': 'What should I record next?'}, ...)]
5 
6 === Final state after resume ===
7   step one done
8   human said: hello from the human
9   step three done

The first .invoke() returns after step_one but before step_two finishes; the log has one entry, and __interrupt__ in the returned state holds the payload the node passed to interrupt(). The second .invoke() uses Command(resume="hello from the human"); the resume value goes back to the paused interrupt() call, which returns it, and step_two proceeds. Step_three runs afterwards as normal.

Two things worth internalizing:

  • The interrupt happens inside the node body, but the caller decides the payload’s meaning. The graph does not know or care that “reject” is different from “approve”; it just hands whatever value it receives back to the paused node.
  • Resume is not restart. When you resume, the node continues from the interrupt() call; it does not re-run from the top. Anything the node did before the interrupt is intact, and no state changes have been committed yet (nodes commit their return value on completion, not during execution).

Example 2: a tool-approval gate

Now let’s look at a realistic use case. An agent has two tools; one is safe (multiply), the other is dangerous (send_email, mocked here so the example is self-contained). The tool node inspects each pending tool call: if the tool is on the dangerous list, it calls interrupt() with the tool name and args, waits for a decision from the caller, and either runs the tool or records a rejection.

Example source-code/langgraph_hitl/02_approval_gate.py shows the two new pieces on top of the standard ReAct shape:

 1 DANGEROUS = {"send_email"}
 2 
 3 
 4 def approving_tools(state: State) -> dict:
 5     last = state["messages"][-1]
 6     tool_messages = []
 7     for call in last.tool_calls:
 8         if call["name"] in DANGEROUS:
 9             decision = interrupt(
10                 {
11                     "type": "approval_request",
12                     "tool": call["name"],
13                     "args": call["args"],
14                 }
15             )
16             if decision != "approve":
17                 tool_messages.append(
18                     ToolMessage(
19                         content=f"[user rejected {call['name']}]",
20                         tool_call_id=call["id"],
21                         name=call["name"],
22                     )
23                 )
24                 continue
25         result = TOOLS_BY_NAME[call["name"]].invoke(call["args"])
26         tool_messages.append(
27             ToolMessage(
28                 content=str(result),
29                 tool_call_id=call["id"],
30                 name=call["name"],
31             )
32         )
33     return {"messages": tool_messages}

Compared to the vanilla ToolNode from Chapter “Building a ReAct Agent with LangGraph + Ollama”, this custom node adds one branch: if the tool is dangerous, call interrupt(), and act on the returned decision. If the decision is "approve" the tool runs. Otherwise the node records a ToolMessage with content "[user rejected send_email]" so the model sees on the next turn that its request was refused and can respond accordingly.

The driver code runs three cases through the same agent (safe tool, dangerous tool approved, dangerous tool rejected), feeding a scripted list of “approve” / “reject” responses to whatever interrupts fire. Representative output:

 1 $ uv run 02_approval_gate.py
 2 --- Case 1: safe tool, no approval needed ---
 3   final: 137 times 24 is 3288.
 4 
 5 --- Case 2: dangerous tool, human approves ---
 6   [interrupt] approval requested for send_email({'to': 'alice@example.com', 'subject': 'Hi', 'body': 'Hello'})
 7   [human]     decision = 'approve'
 8   final: The email has been sent to alice@example.com with the subject "Hi".
 9 
10 --- Case 3: dangerous tool, human rejects ---
11   [interrupt] approval requested for send_email({'to': 'bob@example.com', 'subject': 'Meeting', 'body': 'Tomorrow 10am'})
12   [human]     decision = 'reject'
13   final: The user rejected sending the email, so no message was sent.

Notice how the model handles the rejection gracefully in case 3. It sees the [user rejected send_email] ToolMessage on the next turn, reasons that no email was sent, and produces a sensible final answer. That is why we return a ToolMessage rather than raising: the model needs to know its action was refused so it can adapt.

This approval pattern generalizes. Any tool with side effects (filesystem writes, database mutations, external API calls, financial transactions) belongs in the DANGEROUS set until you can prove the model handles it responsibly. Start conservative and remove tools from the list as you build evidence for each one.

Example 3: pause after a node, edit the checkpoint, resume

The third mechanism is compile-time interrupts combined with update_state(). Instead of the node itself deciding to pause, you tell the compiler “always pause after this node runs,” inspect the recorded state, edit it, then resume.

The next example source-code/langgraph_hitl/03_edit_draft.py alters a node using a LLM inference call in lines 26-38:

 1 from typing import TypedDict
 2 
 3 from langchain_core.messages import HumanMessage
 4 from langchain_ollama import ChatOllama
 5 from langgraph.checkpoint.memory import MemorySaver
 6 from langgraph.graph import END, START, StateGraph
 7 
 8 
 9 class State(TypedDict):
10     topic: str
11     proposal: str
12     refined: str
13 
14 
15 model = ChatOllama(model="qwen3.5:4b", temperature=0)
16 
17 
18 def propose(state: State) -> dict:
19     reply = model.invoke(
20         [HumanMessage(content=f"Write a one-sentence description of {state['topic']}.")]
21     )
22     return {"proposal": reply.content.strip()}
23 
24 
25 def refine(state: State) -> dict:
26     reply = model.invoke(
27         [
28             HumanMessage(
29                 content=(
30                     "Rewrite the following in a formal, encyclopedic tone. "
31                     "Return only the rewritten sentence.\n\n"
32                     f"{state['proposal']}"
33                 )
34             )
35         ]
36     )
37     return {"refined": reply.content.strip()}
38 
39 
40 graph = StateGraph(State)
41 graph.add_node("propose", propose)
42 graph.add_node("refine", refine)
43 graph.add_edge(START, "propose")
44 graph.add_edge("propose", "refine")
45 graph.add_edge("refine", END)
46 
47 agent = graph.compile(
48     checkpointer=MemorySaver(),
49     interrupt_after=["propose"],
50 )
51 
52 config = {"configurable": {"thread_id": "draft-demo"}}
53 
54 agent.invoke({"topic": "Sedona, Arizona", "proposal": "", "refined": ""}, config=config)
55 
56 paused = agent.get_state(config)
57 print(f"=== Draft produced by 'propose' ===")
58 print(f"  {paused.values['proposal']}\n")
59 
60 edited = "Sedona is a small town in Arizona famous for its red-rock landscape."
61 print(f"=== Human overwrites the draft ===")
62 print(f"  {edited}\n")
63 agent.update_state(config, {"proposal": edited})
64 
65 final = agent.invoke(None, config=config)
66 
67 print(f"=== 'refine' output (using the edited draft) ===")
68 print(f"  {final['refined']}")

Three pieces are new relative to earlier chapters.

interrupt_after=["propose"] at compile time. The graph pauses after propose completes, before refine starts. No interrupt() call inside either node.

agent.update_state(config, {"proposal": edited}) overwrites part of the recorded state. update_state behaves exactly like a node return would: it merges its dict into the current state through the field’s reducer (or replaces the field if no reducer is set). You can update any field, including messages if you want to edit the transcript directly.

agent.invoke(None, config=config) resumes without injecting new input. None is the signal for “just continue from where you paused.” If you passed a dict here it would be applied as an additional state update before continuing, which is another way to inject changes.

Representative output (LLM wording will vary):

1 $ uv run 03_edit_draft.py
2 === Draft produced by 'propose' ===
3   Sedona, Arizona is a scenic city in northern Arizona known for its striking red sandstone formations.
4 
5 === Human overwrites the draft ===
6   Sedona is a small town in Arizona famous for its red-rock landscape.
7 
8 === 'refine' output (using the edited draft) ===
9   Sedona is a small municipality located in the state of Arizona, distinguished by its notable red-rock geological features.

The refine node runs on the edited draft, not the original one. From the graph’s point of view, nothing unusual happened: the propose node produced state, then the refine node ran on the state it found. The fact that a human sat in between and modified the state is invisible to the nodes themselves. That is exactly the encapsulation you want for HITL: the graph’s business logic does not know or care whether a human is watching.

Two mechanisms, when to reach for each

Both interrupt() and interrupt_after+update_state can pause a graph and involve a human. They are not interchangeable.

Use interrupt() when the pause is a decision request and the node needs the human’s answer to keep going. Approval gates, clarifying questions, “which of these options should I pick” prompts. The node’s execution logic depends on what the human says.

Use interrupt_after + update_state when the pause is a review point and the human is editing intermediate output. Draft review, retrieval curation, transcript correction. The node has already done its job; the human is modifying the output before the next node consumes it.

You can also combine both in the same graph: an interrupt() for an approval decision, then an interrupt_after on a downstream node so the human can edit the tool’s result before the model reads it. Any node graph can have any mix.

What we covered

  • HITL requires a checkpointer (already covered in Chapter “Durable, Restart-Safe Agents”): the pause has to save the state somewhere.
  • interrupt(payload) inside a node halts the graph. Caller sees the payload in the returned state’s __interrupt__ key.
  • Command(resume=value) on the next .invoke() resumes the paused node with value as the return of interrupt().
  • interrupt_before=[node] and interrupt_after=[node] at .compile() time create pause points without any special code in the nodes.
  • agent.update_state(config, values) edits the recorded state of a paused graph before resuming.
  • .invoke(None, config=config) resumes from a compile-time pause without injecting new input.

The next chapter “Multi-Agent Supervisor Pattern” combines everything so far: multiple specialized agents, a coordinating supervisor graph, and (optionally) human approvals on the transitions between them.