LangGraph Agents

Course Content

LangGraph Agents

7 sections · 49 lessons

How do you implement human-in-the-loop approvals using persistence and resumption?


A Rs 8,500 refund waiting for a reviewerapprove callsinterrupt(payload)checkpointsaved,process freeops queueshows thepayloadCommand(resume)on any podpay with keyrefund-OD-881231,240 refunds paused over a festival weekend at no compute cost.
The pause lives in the database, not in a waiting thread, which is why a Monday approval can resume a Saturday run.

What you need to know

Python
from typing import TypedDictfrom langgraph.graph import StateGraph, START, ENDfrom langgraph.checkpoint.memory import InMemorySaverfrom langgraph.types import interrupt, Commandclass Refund(TypedDict):    order_id: str    amount_inr: int    status: strdef approve(state: Refund) -> dict:    if state["amount_inr"] <= 5000:        return {"status": "auto_approved"}    decision = interrupt({"order_id": state["order_id"],                          "amount_inr": state["amount_inr"],                          "question": "Approve this refund?"})    return {"status": "approved" if decision["ok"] else "rejected"}def pay(state: Refund) -> dict:    if state["status"] in ("approved", "auto_approved"):        print(f"paying {state['order_id']} with key refund-{state['order_id']}")    return {}b = StateGraph(Refund)b.add_node("approve", approve)b.add_node("pay", pay)b.add_edge(START, "approve"); b.add_edge("approve", "pay"); b.add_edge("pay", END)graph = b.compile(checkpointer=InMemorySaver())cfg = {"configurable": {"thread_id": "refund-OD-88123"}}first = graph.invoke({"order_id": "OD-88123", "amount_inr": 8500, "status": "new"}, cfg)print(first["__interrupt__"][0].value)           # show this to the reviewerprint(graph.invoke(Command(resume={"ok": True}), cfg))

This runs as written.

Patterns

  • Approve or reject — as above.
  • Edit — the reviewer returns corrected values, e.g. {"ok": True, "amount_inr": 6000}.
  • Review a tool call — an interrupt before the tool node shows the tool name and arguments.
  • Ask the user — the same mechanism collects missing information.

For create_agent, HumanInTheLoopMiddleware(interrupt_on={"issue_refund": True}) pauses before that tool, and you resume with Command(resume={"decisions": [{"type": "approve"}]}); other decision types are edit, reject and respond.

Rules

  • Payloads must be JSON-serialisable.
  • Do not wrap interrupt() in try/except Exception: it works by raising a special exception, and a broad except swallows the pause.
  • Several interrupts in one node are matched by order; parallel interrupts can be resumed together with a dict of {interrupt_id: value}.
  • interrupt_before=[...] at compile time is a static breakpoint, useful for debugging, not for approvals.

A real-life example

A marketplace's refund agent auto-approves refunds up to Rs 5,000 and pauses above that. The API stores the interrupt payload in an "approvals" queue in the ops dashboard. On a festival-sale weekend 1,240 refunds paused; reviewers cleared them over 30 hours, some on the Monday morning. Each click sent Command(resume={"ok": ...}) to the same thread, on whichever pod received the request, because checkpoints were in Postgres. The pay node, after the approval, sent the gateway an idempotency key refund-<order_id>, so a double-click never paid twice.

Follow-up questions to expect

  • "What if the human never answers?" — The thread stays paused at no compute cost. Add an expiry job that resumes with a timeout decision, such as auto-reject with a message to the customer.
  • "How is the payload shown to the reviewer?" — Your API reads __interrupt__ from the result, or get_state(config).interrupts, and renders it; with stream, the interrupt arrives as an event.
  • "Why not just block and wait for input?" — A blocked process ties up memory and dies on restart. A checkpointed interrupt costs nothing while waiting and survives deploys.