Skip to content
allsrc.dev
Go back

Pattern 2: HITL Approval Gate

A Human-in-the-Loop (HITL) Approval Gate is a targeted control pattern that pauses an agent’s execution to require explicit human authorization for high-risk or irreversible actions. Pause an agent flow only for the exact actions that deserve a human decision, and bind the resume to that decision.

Why HITL Approval Gates are Necessary

The instinct when adding human oversight to an agent system is to add it everywhere. Every tool call, every decision, every output gets reviewed before proceeding. This feels safe. In practice it produces something that is not an agent system at all. It is a manual workflow with extra steps and a higher infrastructure bill.

The design question is not whether to include human oversight. It is precisely where it should sit, and where it should not. An approval gate that fires at the wrong points destroys the value of automation while only partially addressing the risks it was meant to manage. Human judgment is expensive and slow, so it should be spent where the decision is irreversible, high risk, or outside the agent’s verified competence — and nowhere else.

How a HITL Approval Gate Works

A HITL approval gate pauses an agent flow only when a policy or classifier marks the proposed action as requiring review. The human reviewer can approve, edit, or reject the action, and the resumed flow uses that explicit decision.

flowchart TD
  A[Agent proposes action] --> B[Risk policy]
  B --> C{Risk level}
  C -->|low or medium| D[Continue automatically]
  C -->|high or critical| E[Create ApprovalRequest]
  E --> F{Human decision}
  F -->|approve| G[Resume original action]
  F -->|edit| H[Resume edited action]
  F -->|reject| I[Stop flow]
  D --> J[Audit event]
  G --> J
  H --> J
  I --> J

The gate is small on purpose. It records skipped approvals for low-risk work and creates an ApprovalRequest for high-risk work:

request = gate.maybe_pause(
    "restart_service",
    {"service": "orders-api", "environment": "prod"},
    "high",
)

assert request is not None

result = gate.resume(request.approve())
assert result["status"] == "resumed"

The important behavior is not the UI. It is the state transition: a high-risk action produces a pending request, and the resumed flow uses the approved or edited parameters.

LangGraph has a native primitive for exactly this: interrupt(). A human_review node calls it, which checkpoints the graph state and pauses the run until a caller resumes with Command(resume=...). Only risky tool calls route through the interrupt; safe calls skip straight to the tools node. The reviewer’s decision then routes to the tools node or a terminal blocked node:

def human_review(state: AgentState) -> dict:
    """Pause for a human decision on the exact proposed action."""
    call = state["messages"][-1].tool_calls[0]
    decision = interrupt({"action": call["name"], "parameters": call["args"], "risk": "high"})
    return {"approved": decision == "approve"}

def route_agent(state) -> Literal["human_review", "tools", "__end__"]:
    last = state["messages"][-1]
    if not getattr(last, "tool_calls", None):
        return END
    return "human_review" if any(tc["name"] in RISKY_TOOLS for tc in last.tool_calls) else "tools"

builder.add_conditional_edges("agent", route_agent,
    {"human_review": "human_review", "tools": "tools", END: END})
builder.add_conditional_edges("human_review", route_review, {"tools": "tools", "blocked": "blocked"})
graph = builder.compile(checkpointer=MemorySaver())

Running it pauses at the interrupt and resumes on the human’s decision — the graph survives the wait because state is checkpointed.

Problems and Considerations

Consider the following factors when you implement this pattern:

References



Previous Post
Pattern 1: Tool Privilege Broker
Next Post
Pattern 3: Decision Trace and Audit