teachyou.ai academy
← All posts
AI Agents

Human-in-the-Loop Agent Design Patterns

Pramod Dutta · Jun 3, 2026 · 15 min read

Why your autonomous agent needs a human it can call

Somewhere between the demo and production, every agent builder learns the same lesson: full autonomy is a liability, not a feature. The agent that books meetings flawlessly in a sandbox will eventually try to cancel a client's renewal contract because it misread an email thread. The coding agent that refactors your test suite beautifully will eventually rm -rf the wrong directory because a tool call had a typo in a path variable. These aren't hypothetical failure modes — they are the median outcome of running an LLM-driven agent against real-world state with no checkpoint between "the model decided" and "the action happened."

Human-in-the-loop (HITL) design isn't a concession to nervous stakeholders. It's an architectural pattern, the same way retries, circuit breakers, and rate limiting are architectural patterns in distributed systems. The goal isn't to slow the agent down everywhere — it's to insert a human exactly where the cost of a wrong action is high and the cost of a short delay is low, and to get out of the human's way everywhere else. Done well, HITL makes agents *more* autonomous over time, not less, because you can safely expand the agent's authority once you have telemetry on where it actually needs supervision.

This article walks through the concrete design patterns for building HITL into agent systems: where to place the checkpoints, how to structure approval payloads so a human can decide in seconds, how to route by confidence and risk instead of blanket-gating everything, and how to build the escalation and audit infrastructure that makes this sustainable at scale. We'll write actual code, not just describe the shape of the idea, because the failure mode of most HITL discussions is that they stay abstract and nobody ships the interrupt logic.

The core insight: checkpoints, not supervision

The naive version of "human in the loop" is a human watching every agent action and clicking approve. That doesn't scale past a demo. The pattern that actually works in production is checkpointing: the agent runs autonomously through a plan, and execution pauses only at specific, pre-declared decision points where the action is irreversible, expensive, or ambiguous.

Think of it like a CI/CD pipeline. You don't have an engineer manually verify every line of generated assembly — you gate the pipeline at specific stages (tests pass, staging deploy succeeds, a human clicks "promote to prod"). Agent HITL should look the same. You're not building a babysitter, you're building gates.

There are four categories of gate worth knowing, because they map to different implementation patterns:

  • Pre-execution approval — the agent proposes an action and waits for a yes/no/edit before running it. Best for irreversible actions: sending an email, executing a payment, deleting a record, merging a PR.
  • Post-execution review — the agent acts, then a human reviews a batch of actions asynchronously and can roll back. Best for reversible, low-blast-radius actions where throughput matters more than per-action latency.
  • Escalation on uncertainty — the agent attempts the task, detects it's outside its competence or confidence threshold, and hands off to a human mid-task rather than guessing.
  • Steering / correction loops — a human doesn't just approve or reject, they redirect: "no, use the Stripe test key, not live" — and the agent incorporates that correction into the rest of its run.

Most production agents need at least two of these, layered. Let's build them.

Pattern 1: the approval gate (pre-execution)

The simplest and most common pattern is intercepting a tool call before it executes, presenting it to a human, and only proceeding on explicit approval. The key design decision is *where* this lives in your architecture — it should be a property of the tool, not the agent's prompt. Never rely on "please ask the user before deleting anything" in a system prompt as your only safety mechanism. Models are eventually going to skip that instruction, either because of a distractor in context or a subtly rephrased request. Enforce it in code.

from dataclasses import dataclass
from enum import Enum
from typing import Callable, Any

class ApprovalStatus(Enum):
    PENDING = "pending"
    APPROVED = "approved"
    REJECTED = "rejected"
    EDITED = "edited"

@dataclass
class ApprovalRequest:
    tool_name: str
    arguments: dict
    reason: str
    risk_level: str  # "low", "medium", "high"

class ApprovalGatedTool:
    """Wraps any tool function so it cannot execute without
    a human decision when the risk level warrants it."""

    def __init__(self, fn: Callable, name: str, risk_level: str,
                 requires_approval: bool = True):
        self.fn = fn
        self.name = name
        self.risk_level = risk_level
        self.requires_approval = requires_approval

    def call(self, approval_callback: Callable[[ApprovalRequest], tuple], **kwargs) -> Any:
        if not self.requires_approval:
            return self.fn(**kwargs)

        request = ApprovalRequest(
            tool_name=self.name,
            arguments=kwargs,
            reason=kwargs.get("_reason", "no reason provided"),
            risk_level=self.risk_level,
        )

        status, final_args = approval_callback(request)

        if status == ApprovalStatus.APPROVED:
            return self.fn(**kwargs)
        elif status == ApprovalStatus.EDITED:
            return self.fn(**final_args)
        else:
            return {"error": "action_rejected_by_human", "tool": self.name}


def cli_approval_callback(request: ApprovalRequest):
    print(f"\n[APPROVAL NEEDED - risk: {request.risk_level}]")
    print(f"Tool: {request.tool_name}")
    print(f"Args: {request.arguments}")
    print(f"Reason: {request.reason}")
    decision = input("Approve? [y/n/e(dit)]: ").strip().lower()

    if decision == "y":
        return ApprovalStatus.APPROVED, request.arguments
    elif decision == "e":
        # In a real system this opens a structured edit form,
        # not a raw input() — shown simplified here.
        edited = dict(request.arguments)
        field = input("Field to edit: ")
        value = input("New value: ")
        edited[field] = value
        return ApprovalStatus.EDITED, edited
    return ApprovalStatus.REJECTED, None

Notice what this buys you: the approval logic is a *wrapper*, decoupled from the agent's reasoning loop. The agent still calls send_email(to=..., subject=..., body=...) exactly like any other tool call. It has no idea an approval gate exists in the middle — which is exactly what you want, because it means you can add, remove, or tune gates without touching the agent's prompt or its planning logic at all. This separation of concerns is the single most important idea in this article: authorization is infrastructure, not a prompting problem.

Pattern 2: confidence-based routing

Gating every single tool call defeats the purpose of building an agent in the first place — you've just built an expensive form-filler. The better pattern is to route based on the agent's own confidence, or on a risk score computed from the action's properties, and only interrupt when it's warranted.

There are two ways to get a confidence signal: ask the model to self-report it (works, but models are not perfectly calibrated and tend toward overconfidence), or compute risk from the action itself (more reliable, because it's deterministic and auditable). In practice, the best systems combine both — self-reported confidence as a soft signal, deterministic risk rules as a hard floor.

from dataclasses import dataclass, field

@dataclass
class RiskRule:
    """A deterministic rule that forces escalation regardless
    of what the model thinks its own confidence is."""
    condition: Callable[[dict], bool]
    reason: str

class RiskRouter:
    def __init__(self):
        self.hard_rules: list[RiskRule] = []

    def add_rule(self, condition: Callable[[dict], bool], reason: str):
        self.hard_rules.append(RiskRule(condition, reason))

    def route(self, tool_name: str, args: dict, model_confidence: float) -> str:
        # Deterministic rules always win — a confident model
        # attempting a dangerous action still gets stopped.
        for rule in self.hard_rules:
            if rule.condition(args):
                return f"escalate: {rule.reason}"

        if model_confidence >= 0.90:
            return "auto_execute"
        elif model_confidence >= 0.60:
            return "post_hoc_review"
        else:
            return "escalate: low_model_confidence"


router = RiskRouter()

router.add_rule(
    lambda args: args.get("amount", 0) > 5000,
    "payment exceeds auto-approval threshold"
)
router.add_rule(
    lambda args: "prod" in str(args.get("environment", "")).lower(),
    "action targets production environment"
)
router.add_rule(
    lambda args: args.get("recipient_count", 0) > 100,
    "bulk communication requires review"
)

decision = router.route(
    tool_name="issue_refund",
    args={"amount": 8000, "environment": "prod"},
    model_confidence=0.95,
)
print(decision)  # escalate: payment exceeds auto-approval threshold

The important architectural detail here is the ordering: hard rules are checked *before* confidence is even consulted. A model that is 99% confident about deleting a production database should still be stopped, because confidence measures "how sure am I this is what the user wants," not "how bad is it if I'm wrong." Those are different axes, and conflating them is one of the most common HITL design mistakes — teams build a confidence threshold, watch a high-confidence action cause an incident, and then bolt on hard rules reactively. Build the hard rules first.

Pattern 3: structuring approval payloads a human can actually judge

A gate is worthless if the human on the other end can't make a fast, correct decision. The most common failure here is dumping raw JSON tool arguments in front of a person and expecting them to reason about consequences. If your approval UI shows:

{"action": "update_row", "table": "subscriptions", "id": 88213, "fields": {"status": "cancelled"}}

...the human has to reverse-engineer what that means for a real customer. They'll either rubber-stamp it (defeating the point of the gate) or spend five minutes digging through your admin panel to understand context (defeating the point of automation). The fix is to make the agent generate a human-readable diff of consequences, not just the raw call.

@dataclass
class ConsequencePreview:
    summary: str
    before_state: dict
    after_state: dict
    affected_entities: list[str]
    reversible: bool
    estimated_impact: str

def build_consequence_preview(action: str, current_row: dict, proposed_changes: dict) -> ConsequencePreview:
    after = {**current_row, **proposed_changes}
    diffs = [
        f"{k}: '{current_row.get(k)}' -> '{v}'"
        for k, v in proposed_changes.items()
        if current_row.get(k) != v
    ]
    return ConsequencePreview(
        summary=f"{action} on record {current_row.get('id')}: " + "; ".join(diffs),
        before_state=current_row,
        after_state=after,
        affected_entities=[current_row.get("customer_email", "unknown")],
        reversible=action not in ("delete", "hard_cancel", "purge"),
        estimated_impact="Customer loses access at next billing cycle"
            if proposed_changes.get("status") == "cancelled" else "low",
    )

preview = build_consequence_preview(
    action="update_row",
    current_row={"id": 88213, "status": "active", "customer_email": "a@example.com"},
    proposed_changes={"status": "cancelled"},
)
print(preview.summary)
# update_row on record 88213: status: 'active' -> 'cancelled'

Require the agent to produce this preview as part of its own reasoning, before the tool call is even dispatched to the approval queue. This has a side benefit beyond UX: forcing the model to articulate "here's what changes and here's who it affects" before acting is itself a mild form of self-critique that catches a surprising number of mistakes — the model sometimes realizes mid-explanation that the action doesn't match the user's actual request.

Pattern 4: escalation queues and mid-task handoff

Approval gates handle "should this single action happen." Escalation handles a harder problem: the agent is *mid-task*, has already done useful work, and has hit something it genuinely cannot resolve — an ambiguous instruction, a tool error it can't recover from, conflicting data. The wrong move is to let the agent guess and keep going. The second-wrong move is to throw away all the progress and dump a blank ticket on a human. The right move is to package everything the agent has learned so far into a handoff object.

from datetime import datetime, timezone

@dataclass
class EscalationTicket:
    task_id: str
    original_goal: str
    steps_completed: list[str]
    blocking_issue: str
    agent_hypothesis: str
    options_considered: list[str]
    context_snapshot: dict
    created_at: str = field(default_factory=lambda: datetime.now(timezone.utc).isoformat())

class EscalationQueue:
    def __init__(self):
        self._tickets: dict[str, EscalationTicket] = {}

    def escalate(self, ticket: EscalationTicket) -> str:
        self._tickets[ticket.task_id] = ticket
        # In production: push to Slack, PagerDuty, or an internal
        # review dashboard, tagged by urgency and team.
        return ticket.task_id

    def resume_with_human_input(self, task_id: str, human_decision: str) -> dict:
        ticket = self._tickets.pop(task_id, None)
        if not ticket:
            raise ValueError("no such escalation")
        return {
            "task_id": task_id,
            "resume_context": ticket.steps_completed,
            "human_decision": human_decision,
        }


queue = EscalationQueue()

ticket = EscalationTicket(
    task_id="refund-flow-4471",
    original_goal="Process refund request from ticket #4471",
    steps_completed=[
        "Verified purchase exists (order #99213)",
        "Confirmed refund window is still open",
        "Found conflicting policy: standard refund vs promo-bundle refund rules differ",
    ],
    blocking_issue="Order used a promo bundle; two refund policies apply and give different amounts",
    agent_hypothesis="Likely the promo-bundle policy applies since promo_code field is set, but I'm not authorized to interpret contract terms",
    options_considered=["Apply standard refund ($49)", "Apply promo-bundle refund ($31)"],
    context_snapshot={"order_id": 99213, "promo_code": "SUMMER25"},
)

ticket_id = queue.escalate(ticket)

This is the difference between an escalation that a human resolves in thirty seconds and one that takes fifteen minutes of archaeology. The agent did the legwork — it verified the order, checked the window, identified *exactly* which two policies conflict, and proposed both possible outcomes. The human's job shrinks to "pick A or B," which is the correct allocation of effort: machines do retrieval and enumeration, humans do judgment calls that require authority or context the agent doesn't have.

A subtlety worth calling out: the escalation ticket should always include what the agent *already tried*, not just what it's stuck on. Without steps_completed, humans routinely re-verify work the agent already did correctly, wasting the exact time HITL was supposed to save.

Pattern 5: correction loops, not just approve/reject

Binary approve/reject is the minimum viable HITL pattern, but it wastes the human's expertise. If a human rejects an action, that's a dead end — the agent learns nothing about *why*, and either retries blindly or gives up. The stronger pattern is a correction loop, where the human's input becomes new context the agent incorporates into the rest of the run, not just a verdict on one step.

@dataclass
class Correction:
    original_action: dict
    human_feedback: str
    corrected_action: dict | None

def apply_correction_to_agent_context(agent_state: dict, correction: Correction) -> dict:
    """Fold a human correction back into working memory so the
    agent doesn't repeat the same category of mistake later
    in the same run."""
    agent_state.setdefault("learned_constraints", [])
    agent_state["learned_constraints"].append({
        "trigger": correction.original_action.get("tool_name"),
        "constraint": correction.human_feedback,
    })
    return agent_state

state = {"task": "migrate customer records", "learned_constraints": []}

correction = Correction(
    original_action={"tool_name": "bulk_update", "table": "customers"},
    human_feedback="Never touch rows where account_type = 'enterprise' without a second approval, even mid-batch.",
    corrected_action=None,
)

state = apply_correction_to_agent_context(state, correction)
# Every subsequent planning step for this run now includes
# state["learned_constraints"] in its context window.

In practice this means: every time you inject the agent's working state back into the prompt for the next step, you include learned_constraints. This turns a single correction into a standing rule for the rest of that run — the human effectively patches the agent's judgment in real time instead of relitigating the same objection on every subsequent enterprise-account row. Some teams take this further and persist corrections across runs entirely, building a lightweight rules memory that gets reviewed and promoted into the system prompt periodically. That's a reasonable evolution, but start with per-run correction memory — it's simpler and it's where most of the value is.

Designing the audit trail (because you will need it)

Every HITL system eventually gets asked "why did the agent do that, and who approved it." If you can't answer that in under a minute, you have a compliance problem waiting to happen, especially once agents touch money, health data, or user PII. The audit log is not optional tooling — treat it as part of the core design, not something bolted on after an incident.

At minimum, log:

  • The full tool call the agent proposed, verbatim
  • The consequence preview shown to the human
  • Who approved/rejected/edited it, and when
  • The model's self-reported confidence and the risk router's decision
  • The final action actually executed (which may differ from the proposal, if edited)
import json
import uuid

class AuditLog:
    def __init__(self, path: str):
        self.path = path

    def record(self, request: ApprovalRequest, decision: ApprovalStatus,
               approver: str, final_args: dict):
        entry = {
            "event_id": str(uuid.uuid4()),
            "tool_name": request.tool_name,
            "proposed_args": request.arguments,
            "final_args": final_args,
            "risk_level": request.risk_level,
            "decision": decision.value,
            "approver": approver,
            "timestamp": datetime.now(timezone.utc).isoformat(),
        }
        with open(self.path, "a") as f:
            f.write(json.dumps(entry) + "\n")

This log doubles as your feedback data. Once you've accumulated a few weeks of decisions, you can go back and ask: which risk categories get approved 99% of the time with no edits? Those are candidates for loosening — move them from pre-execution approval to post-execution review, or raise the auto-execute confidence threshold for that tool. Which categories get edited or rejected often? Those need either a better prompt, a better tool interface, or a permanent human gate. HITL isn't a static configuration you set once; it's a system you tune against real approval data, the same way you'd tune alerting thresholds against real on-call data.

Common mistakes that undermine HITL systems

A few patterns show up repeatedly in teams that build HITL and then find it doesn't actually reduce incidents:

  • Gating everything equally. If low-risk and high-risk actions both require the same approval flow, humans start rubber-stamping out of fatigue, and the gate stops meaning anything. Tune risk tiers deliberately.
  • Putting the safety logic in the prompt instead of the code. "Always ask before deleting" is not a control, it's a suggestion the model can forget under context pressure. Enforce gates at the tool-execution layer.
  • Showing raw arguments instead of consequences. If a human can't tell what will happen without reading your database schema, they'll either stall or rubber-stamp.
  • Treating rejection as a dead end. Without a correction loop, every rejection is a wasted training signal and the agent will likely make a similar mistake next time.
  • No audit trail. You cannot tune thresholds you never measured, and you cannot answer "who approved this" months later without one.
  • Confusing confidence with risk. A confident agent and a risky action are independent variables. Hard rules for risk should always be checked ahead of soft confidence thresholds.

Closing thoughts

Human-in-the-loop design is what separates agents that survive contact with production from agents that generate a viral incident post-mortem. The pattern isn't complicated in principle — checkpoint at the right moments, make the checkpoint cheap for the human to resolve, and route by risk instead of blanket caution — but getting the details right takes deliberate engineering: consequence previews, hard risk rules, escalation tickets that preserve work already done, and correction loops that let a human's judgment propagate through the rest of a run instead of evaporating after one decision.

If you want to go deeper on building agents that are genuinely production-ready rather than demo-ready — covering tool design, memory, multi-agent orchestration, and yes, the full HITL approval and escalation architecture in a real codebase — that's exactly what we built 30 Days of Hermes Agent to teach. It's a project-based course, not a slide deck, and human-in-the-loop patterns are treated as first-class architecture from day one rather than an afterthought bolted on after something breaks.