TechByteByByte

Human-in-the-Loop: Approve, Reject, Modify, Resume

Real applications need more than a yes/no interrupt. Build all four genuine patterns — approval, rejection, modification, and escalation on timeout — on top of Module 19's real mechanism.

#LangGraph#Human-in-the-Loop#Interrupts

Module 19 gave you the real, working mechanism — interrupt(), checkpointed, resumed with Command(resume=...). This module builds on top of it directly, covering the genuine range of real, practical patterns a human-in-the-loop system actually needs, beyond a simple true-or-false.

Pattern 1: simple approval

Recall Module 19’s own core example directly — this is the pattern already fully built. Worth restating cleanly as the baseline every other pattern extends.

decision = interrupt({"question": "Approve this action?"})
if decision:
    # proceed
    ...

Pattern 2: rejection with a reason

Real rejections are rarely a bare “no” — a genuine reviewer usually wants to record why.

from typing import TypedDict
from langgraph.graph import StateGraph, START, END
from langgraph.types import interrupt, Command
from langgraph.checkpoint.memory import InMemorySaver

class State(TypedDict):
    refund_amount: float
    outcome: str

def request_approval(state: State) -> dict:
    response = interrupt({"question": f"Approve ${state['refund_amount']} refund?"})
    if response["decision"] == "approve":
        return {"outcome": "Refund approved and processed."}
    return {"outcome": f"Refund rejected. Reason: {response.get('reason', 'none given')}"}

builder = StateGraph(State)
builder.add_node("request_approval", request_approval)
builder.add_edge(START, "request_approval")
builder.add_edge("request_approval", END)

graph = builder.compile(checkpointer=InMemorySaver())
config = {"configurable": {"thread_id": "rejection-case"}}

graph.invoke({"refund_amount": 300.0, "outcome": ""}, config=config)
result = graph.invoke(Command(resume={"decision": "reject", "reason": "Order was already refunded last week."}), config=config)
print(result["outcome"])

Notice interrupt()’s payload, and the value resumed via Command(resume=...), are both genuinely just real Python data — here, a dictionary carrying both a decision and a reason, rather than a bare boolean. interrupt() places no real constraint on the shape of this data; design it around what your actual application genuinely needs to record.

Pattern 3: modification before resuming

Sometimes a human doesn’t want to simply approve or reject — they want to genuinely change what’s about to happen first.

from typing import TypedDict
from langgraph.graph import StateGraph, START, END
from langgraph.types import interrupt, Command
from langgraph.checkpoint.memory import InMemorySaver

class State(TypedDict):
    refund_amount: float
    outcome: str

def request_approval(state: State) -> dict:
    response = interrupt({
        "question": "Review this refund before it's processed.",
        "proposed_amount": state["refund_amount"],
    })
    final_amount = response.get("modified_amount", state["refund_amount"])
    return {"outcome": f"Refund of ${final_amount} processed (originally proposed: ${state['refund_amount']})."}

builder = StateGraph(State)
builder.add_node("request_approval", request_approval)
builder.add_edge(START, "request_approval")
builder.add_edge("request_approval", END)

graph = builder.compile(checkpointer=InMemorySaver())
config = {"configurable": {"thread_id": "modification-case"}}

graph.invoke({"refund_amount": 500.0, "outcome": ""}, config=config)
# the human reviewer decides $500 is too high, and genuinely lowers it before approving
result = graph.invoke(Command(resume={"modified_amount": 350.0}), config=config)
print(result["outcome"])

Notice the node itself never assumes the human simply rubber-stamps its own proposal — it genuinely reads whatever real value comes back through resume, using the original proposal only as a fallback default. This is a real, meaningful difference from Patterns 1 and 2: the human isn’t just gating an action, they’re actively, genuinely reshaping it.

Pattern 4: escalation on timeout

Real human review doesn’t always happen promptly. A genuinely production-ready system needs a real answer for “what if nobody responds.”

flowchart TD
    A[interrupt: waiting for a human] --> B{Response within\nreal time limit?}
    B -->|Yes| C[Resume normally\nwith their decision]
    B -->|No| D["Escalate: notify a manager,\nauto-reject, or apply\na safe default"]

LangGraph’s own interrupt() mechanism doesn’t include a timeout directly — a paused thread genuinely waits indefinitely until something resumes it. A real, deliberate timeout is application-level logic sitting around the graph: your own code tracks when a thread paused, and if a real, meaningful amount of time passes with no resume, your application itself decides what to do — auto-reject, escalate to a different reviewer, or apply a conservative, predetermined default, rather than leaving the workflow paused forever.

import time
from langgraph.types import Command

def resume_or_escalate(graph, config, paused_at: float, timeout_seconds: float, human_response=None):
    if human_response is not None:
        return graph.invoke(Command(resume=human_response), config=config)
    if time.time() - paused_at > timeout_seconds:
        return graph.invoke(Command(resume={"decision": "reject", "reason": "Timed out — no reviewer responded."}), config=config)
    return None  # still genuinely waiting

This is deliberately just ordinary Python, sitting outside the graph itself — a real, honest reminder that not every piece of a production system needs to live inside StateGraph. The graph handles the pause and resume mechanism correctly; your surrounding application decides the real, practical policy for how long “waiting” should genuinely last.

Common mistakes worth avoiding

Defaulting to approval on timeout, rather than denial. This is worth stating as a real, documented best practice, not just a stylistic preference: when no reviewer responds within the allotted window, real, current guidance on production human-in-the-loop systems is consistent — the action should deny by default, not silently proceed. Recall Pattern 4’s own resume_or_escalate function — notice it defaults to rejection on timeout, not approval, deliberately matching this real, documented standard.

Treating “a human is in the loop” as automatically meaning the risk is handled. This is a genuine, well-documented phenomenon worth taking seriously: reviewers facing routine approval requests tend to exhibit real “automation bias” — placing more trust in the AI’s proposal than it actually warrants, and approving requests with less real scrutiny over time, especially once most requests turn out to be genuinely fine. A checkpoint a human approves 99 times out of 100 without changing anything isn’t really providing oversight anymore — it’s worth periodically auditing whether your interrupt points are still functioning as genuine review, or have quietly become a formality.

Assuming every interrupt needs the same yes/no shape. Recall Patterns 2 and 3 — interrupt()’s payload and resume value are just real Python data, shaped however your actual workflow genuinely needs. Forcing every human decision into a boolean loses real, useful information a reviewer might want to provide.

Building timeout logic inside the graph itself, rather than around it. Recall Pattern 4 — interrupt() has no native timeout; a paused thread waits genuinely indefinitely. Timeout policy belongs in your surrounding application code, checking real elapsed time against a persisted thread, not inside the graph’s own node logic.

Letting a human’s modification bypass real validation. Recall Pattern 3 — a reviewer lowering a refund amount is reasonable; a reviewer raising it to an absurd, unvalidated number probably shouldn’t be accepted without question. A genuinely production-ready modification pattern still validates the human’s input, exactly like Module 12 (LangChain course) taught for validating a tool’s own input.

What you should take away from this module

  • Approval, rejection, modification, and timeout-escalation are the four genuine, real patterns most human-in-the-loop systems actually need.
  • interrupt()’s payload and its resumed value are unconstrained, real Python data — shape them around what your specific workflow genuinely requires, not a fixed yes/no.
  • Modification means a node reads back whatever real value the human provided, rather than assuming its own original proposal was simply accepted.
  • Timeout and escalation policy is real application logic living around the graph, since interrupt() itself waits indefinitely by design.

Where this goes next

The next module addresses a genuinely different kind of durability: Durable Execution — what changes when an agent needs to run not for a few seconds, but for hours, surviving real process restarts and real external delays along the way.

Author
TechByteByByte Editorial Team
Reviewed by
TechByteByByte Admin
Published
Last reviewed