Week 4 — Interrupt for a human¶
CW-1847’s customer wants their money back while the export is still broken. The bot can draft that refund. It cannot approve it — that write waits for a person to look.
Course details
Course: LangGraph
Who this is for: Engineers who have put a ticket in pending_approval and waited for a Slack reaction.
A homemade ApprovalRequest class is a to-do list. LangGraph’s version is the same StateGraph + checkpointer, paused by calling interrupt(...) inside the node that needs a human. Resume with invoke(Command(resume=decision), config).
🎯 What you will be able to do¶
- Pause a CloudWave refund graph before the write
- Resume three tested paths: approve, reject, needs-info
- Keep the graph small (draft → approve gate → act)
- Know that week 5 still has to key the write
Think of it like… a GitHub required reviewer.
CI is green; merge is blocked until a human hits approve. interrupt(...) is that required check. The checkpoint is the PR. thread_id is the PR number. Command(resume="approve") is the reviewer's click.
Picture the gate¶
START → draft → approve node ──⏸ interrupt({"refund": ...})
▲ │ (checkpointed; the caller gets __interrupt__)
│ ▼
└── invoke(Command(resume="approve" | "reject" | "needs_info"))
│
execute / cancel / ask → END
One small approval graph¶
import operator
from typing import Annotated, TypedDict
from langgraph.checkpoint.memory import InMemorySaver
from langgraph.graph import END, START, StateGraph
from langgraph.types import Command, interrupt
class Refund(TypedDict):
request: str
log: Annotated[list[str], operator.add]
def draft(state: Refund) -> dict:
return {"log": [f"drafted:{state['request']}"]}
def approve(state: Refund) -> dict:
# Pauses here. The payload is what the reviewer sees; the return value is what they decided.
decision = interrupt({"refund": state["request"], "ask": "approve, reject, or needs_info?"})
if decision == "approve":
return {"log": ["executed"]}
if decision == "reject":
return {"log": ["cancelled"]}
return {"log": ["asked-for-info"]}
g = StateGraph(Refund)
g.add_node("draft", draft)
g.add_node("approve", approve)
g.add_edge(START, "draft")
g.add_edge("draft", "approve")
g.add_edge("approve", END)
app = g.compile(checkpointer=InMemorySaver()) # no checkpointer → nothing to resume
def run_path(thread_id: str, decision: str) -> list[str]:
config = {"configurable": {"thread_id": thread_id}}
paused = app.invoke({"request": "refund $50 user_0001", "log": []}, config)
assert "__interrupt__" in paused # stopped at the gate, payload attached
assert "executed" not in paused["log"] # nothing written yet
final = app.invoke(Command(resume=decision), config) # the human's answer
return final["log"]
assert run_path("t-approve", "approve")[-1] == "executed"
assert run_path("t-reject", "reject")[-1] == "cancelled"
assert run_path("t-info", "needs_info")[-1] == "asked-for-info"
Three thread_ids, three decisions, three last log lines. That is the week.
paused["__interrupt__"][0].value is the payload you would render in Slack or an admin page. The reviewer's click becomes Command(resume=...) on the same thread_id.
Watch out — resume re-runs the node from the top
On resume, LangGraph does not jump to the line after interrupt(). It re-executes the whole approve node, and this time interrupt() returns the decision instead of pausing. Anything above the interrupt() call runs twice. Keep side effects after the interrupt, or make them idempotent (week 5). The same rule, from the other side: a decision passed in the first invoke skips the gate entirely — only the resume carries the human's answer.
The older spelling: a static breakpoint¶
You will also see compile(checkpointer=..., interrupt_before=["approve"]), with the decision written by app.update_state(config, {"decision": ...}) and the run resumed by app.invoke(None, config). It still works in 1.x and the agent capstone uses it. The difference: a static breakpoint pauses before a node no matter what, and the human edits state from outside; interrupt() pauses inside the node, can be conditional (“only ask above $500”), and hands the answer straight back to the code that needs it. Prefer interrupt() for approvals; keep interrupt_before for debugging a graph step by step.
Ship / don’t ship
Ship a pause in front of refunds, deletes, and mail that cannot be unsent, with tests for approve / reject / needs-info. Don’t ship an ApprovalRequest class that is not the graph, and don’t combine this week with a loan-underwriting mega-project.
Weeks 1–4 together (not “all 4 weeks” as a capstone)¶
A small refund graph already uses:
- Branching state (week 1)
- A join or extra notify node if you add one (week 2)
- A checkpointer (week 3)
interrupt()+Command(resume=...)(week 4)
Week 5 (idempotency) is not done. Mention it on the write: resume re-runs approve from the top, and so does recovery if the process dies after the HTTP 200.
✍️ Exercise¶
🤔 Reflection¶
- Who is allowed to send
Command(resume=...)for a refund in a real service (which authz)? needs_info— do you loop back todraftor END with a question ticket?- Why is a loan-approval “platform” the wrong exercise for this interrupt?
🔗 Next week¶
Resume is at-least-once. Keys make the charge happen once.