StackPractices
intermediate By Mathias Paulenko

Human-in-the-Loop Pattern

Pause LLM agent execution for human approval before high-impact actions. Route decisions to a reviewer when confidence is low or stakes are high.

Topics: ai

Overview

Left alone, an LLM agent will happily send an email, deploy code, or delete a database row if its plan says so. This pattern inserts a checkpoint where a human looks over the agent’s proposed action before it runs. The agent pauses, shows its plan, and waits for approval, a rejection, or a tweak.

But pausing every step would make the agent useless. Two signals decide which steps stop: a confidence threshold and the action’s risk level. Something harmless like reading a file goes straight through. Pushing to production or emailing a customer always stops for review. It pairs naturally with the LLM guardrails pattern: guardrails shape what the agent may do, this pattern decides who signs off on it.

When to Use

  • The agent can take irreversible actions (deleting data, sending communications, deploying)
  • The cost of a wrong action exceeds the cost of waiting for human review
  • Regulatory or compliance requirements mandate human oversight
  • The agent operates in an unfamiliar environment where confidence is low
  • Examples: deployment agents, email drafting agents, database migration agents, financial transaction agents

When NOT to Use

  • Read-only or fully reversible actions; pausing for those just burns reviewer attention
  • Latency-critical paths where a human in the loop is too slow (autoscaling, real-time routing)
  • When the real need is routing between tools; the agent tool selection pattern covers that decision instead
  • Early prototypes still finding their action space; add the checkpoint once actions stabilize

Solution

Python

from dataclasses import dataclass, field
from typing import Callable, Dict, List, Optional, Any
from enum import Enum

class RiskLevel(Enum):
    LOW = "low"
    MEDIUM = "medium"
    HIGH = "high"

class ApprovalStatus(Enum):
    APPROVED = "approved"
    REJECTED = "rejected"
    MODIFIED = "modified"

@dataclass
class AgentAction:
    name: str
    description: str
    risk: RiskLevel
    parameters: Dict[str, Any] = field(default_factory=dict)
    confidence: float = 1.0

@dataclass
class ApprovalRequest:
    action: AgentAction
    reason: str
    agent_state: Dict[str, Any] = field(default_factory=dict)

@dataclass
class ApprovalResponse:
    status: ApprovalStatus
    modified_parameters: Optional[Dict[str, Any]] = None
    feedback: str = ""

class HumanInTheLoop:
    def __init__(
        self,
        confidence_threshold: float = 0.7,
        auto_approve_low_risk: bool = True,
        reviewer: Optional[Callable[[ApprovalRequest], ApprovalResponse]] = None,
    ):
        self.confidence_threshold = confidence_threshold
        self.auto_approve_low_risk = auto_approve_low_risk
        self.reviewer = reviewer or self._default_reviewer

    def _default_reviewer(self, request: ApprovalRequest) -> ApprovalResponse:
        print(f"\n--- APPROVAL REQUIRED ---")
        print(f"Action: {request.action.name}")
        print(f"Description: {request.action.description}")
        print(f"Risk: {request.action.risk.value}")
        print(f"Confidence: {request.action.confidence:.0%}")
        print(f"Parameters: {request.action.parameters}")
        print(f"Reason: {request.reason}")
        print(f"-------------------------")

        choice = input("Approve? (y/n/m=modify): ").strip().lower()
        if choice == "y":
            return ApprovalResponse(status=ApprovalStatus.APPROVED)
        elif choice == "m":
            new_params = dict(request.action.parameters)
            key = input("Parameter to modify (or empty to skip): ").strip()
            if key and key in new_params:
                new_params[key] = input(f"New value for {key}: ").strip()
            return ApprovalResponse(
                status=ApprovalStatus.MODIFIED,
                modified_parameters=new_params,
            )
        return ApprovalResponse(status=ApprovalStatus.REJECTED, feedback="Rejected by reviewer")

    def needs_approval(self, action: AgentAction) -> bool:
        if action.risk == RiskLevel.HIGH:
            return True
        if action.risk == RiskLevel.MEDIUM and action.confidence < self.confidence_threshold:
            return True
        if action.risk == RiskLevel.LOW and not self.auto_approve_low_risk:
            return True
        return False

    def execute_with_approval(
        self,
        action: AgentAction,
        execute_fn: Callable[[AgentAction], str],
        reason: str = "",
    ) -> str:
        if not self.needs_approval(action):
            print(f"[AUTO] Executing: {action.name}")
            return execute_fn(action)

        request = ApprovalRequest(action=action, reason=reason)
        response = self.reviewer(request)

        if response.status == ApprovalStatus.REJECTED:
            return f"[REJECTED] {action.name}: {response.feedback}"

        if response.status == ApprovalStatus.MODIFIED and response.modified_parameters:
            action.parameters = response.modified_parameters

        print(f"[APPROVED] Executing: {action.name}")
        return execute_fn(action)

# Usage
def mock_execute(action: AgentAction) -> str:
    return f"Executed {action.name} with {action.parameters}"

hitl = HumanInTheLoop(confidence_threshold=0.8)

actions = [
    AgentAction("read_file", "Read config file", RiskLevel.LOW, {"path": "/etc/config.yml"}, 0.95),
    AgentAction("run_tests", "Execute test suite", RiskLevel.LOW, {"suite": "unit"}, 0.9),
    AgentAction("send_email", "Send notification to client", RiskLevel.HIGH, {"to": "client@example.com", "subject": "Update"}, 0.85),
    AgentAction("deploy", "Deploy to production", RiskLevel.HIGH, {"env": "prod", "version": "v2.1.0"}, 0.6),
    AgentAction("delete_records", "Delete stale records", RiskLevel.MEDIUM, {"table": "logs", "older_than": "30d"}, 0.5),
]

for action in actions:
    result = hitl.execute_with_approval(action, mock_execute, reason="Agent workflow step")
    print(f"  Result: {result}\n")

JavaScript

const RiskLevel = { LOW: "low", MEDIUM: "medium", HIGH: "high" };
const ApprovalStatus = { APPROVED: "approved", REJECTED: "rejected", MODIFIED: "modified" };

class AgentAction {
  constructor(name, description, risk, parameters = {}, confidence = 1.0) {
    this.name = name;
    this.description = description;
    this.risk = risk;
    this.parameters = parameters;
    this.confidence = confidence;
  }
}

class ApprovalResponse {
  constructor(status, modifiedParameters = null, feedback = "") {
    this.status = status;
    this.modifiedParameters = modifiedParameters;
    this.feedback = feedback;
  }
}

class HumanInTheLoop {
  constructor(options = {}) {
    this.confidenceThreshold = options.confidenceThreshold ?? 0.7;
    this.autoApproveLowRisk = options.autoApproveLowRisk ?? true;
    this.reviewer = options.reviewer || ((req) => {
      console.log(`\n--- APPROVAL REQUIRED ---`);
      console.log(`Action: ${req.action.name}, Risk: ${req.action.risk}, Confidence: ${(req.action.confidence * 100).toFixed(0)}%`);
      return new ApprovalResponse(ApprovalStatus.APPROVED);
    });
  }

  needsApproval(action) {
    if (action.risk === RiskLevel.HIGH) return true;
    if (action.risk === RiskLevel.MEDIUM && action.confidence < this.confidenceThreshold) return true;
    if (action.risk === RiskLevel.LOW && !this.autoApproveLowRisk) return true;
    return false;
  }

  executeWithApproval(action, executeFn, reason = "") {
    if (!this.needsApproval(action)) {
      console.log(`[AUTO] Executing: ${action.name}`);
      return executeFn(action);
    }

    const response = this.reviewer({ action, reason });

    if (response.status === ApprovalStatus.REJECTED) {
      return `[REJECTED] ${action.name}: ${response.feedback}`;
    }

    if (response.status === ApprovalStatus.MODIFIED && response.modifiedParameters) {
      action.parameters = response.modifiedParameters;
    }

    console.log(`[APPROVED] Executing: ${action.name}`);
    return executeFn(action);
  }
}

// Usage
function mockExecute(action) {
  return `Executed ${action.name} with ${JSON.stringify(action.parameters)}`;
}

const hitl = new HumanInTheLoop({ confidenceThreshold: 0.8 });

const actions = [
  new AgentAction("read_file", "Read config", RiskLevel.LOW, { path: "/etc/config.yml" }, 0.95),
  new AgentAction("send_email", "Notify client", RiskLevel.HIGH, { to: "client@example.com" }, 0.85),
  new AgentAction("deploy", "Deploy to prod", RiskLevel.HIGH, { env: "prod" }, 0.6),
  new AgentAction("delete_records", "Delete stale logs", RiskLevel.MEDIUM, { table: "logs" }, 0.5),
];

for (const action of actions) {
  const result = hitl.executeWithApproval(action, mockExecute, "Agent step");
  console.log(`  Result: ${result}\n`);
}

Java

import java.util.*;

public class HumanInTheLoop {

    enum RiskLevel { LOW, MEDIUM, HIGH }
    enum ApprovalStatus { APPROVED, REJECTED, MODIFIED }

    record AgentAction(String name, String description, RiskLevel risk,
                       Map<String, Object> parameters, double confidence) {}
    record ApprovalResponse(ApprovalStatus status, Map<String, Object> modifiedParams, String feedback) {}
    record ApprovalRequest(AgentAction action, String reason) {}

    private final double confidenceThreshold;
    private final boolean autoApproveLowRisk;
    private final java.util.function.Function<ApprovalRequest, ApprovalResponse> reviewer;

    public HumanInTheLoop(double confidenceThreshold, boolean autoApproveLowRisk,
                          java.util.function.Function<ApprovalRequest, ApprovalResponse> reviewer) {
        this.confidenceThreshold = confidenceThreshold;
        this.autoApproveLowRisk = autoApproveLowRisk;
        this.reviewer = reviewer != null ? reviewer : req -> {
            System.out.printf("%n--- APPROVAL REQUIRED ---%n");
            System.out.printf("Action: %s, Risk: %s, Confidence: %.0f%%%n",
                req.action().name(), req.action().risk(), req.action().confidence() * 100);
            return new ApprovalResponse(ApprovalStatus.APPROVED, null, "");
        };
    }

    boolean needsApproval(AgentAction action) {
        if (action.risk() == RiskLevel.HIGH) return true;
        if (action.risk() == RiskLevel.MEDIUM && action.confidence() < confidenceThreshold) return true;
        if (action.risk() == RiskLevel.LOW && !autoApproveLowRisk) return true;
        return false;
    }

    public String executeWithApproval(AgentAction action,
                                       java.util.function.Function<AgentAction, String> executeFn,
                                       String reason) {
        if (!needsApproval(action)) {
            System.out.println("[AUTO] Executing: " + action.name());
            return executeFn.apply(action);
        }

        var response = reviewer.apply(new ApprovalRequest(action, reason));

        if (response.status() == ApprovalStatus.REJECTED) {
            return "[REJECTED] " + action.name() + ": " + response.feedback();
        }

        System.out.println("[APPROVED] Executing: " + action.name());
        return executeFn.apply(action);
    }

    public static void main(String[] args) {
        var hitl = new HumanInTheLoop(0.8, true, null);

        var actions = List.of(
            new AgentAction("read_file", "Read config", RiskLevel.LOW, Map.of("path", "/etc/config.yml"), 0.95),
            new AgentAction("send_email", "Notify client", RiskLevel.HIGH, Map.of("to", "client@example.com"), 0.85),
            new AgentAction("deploy", "Deploy to prod", RiskLevel.HIGH, Map.of("env", "prod"), 0.6),
            new AgentAction("delete_records", "Delete stale logs", RiskLevel.MEDIUM, Map.of("table", "logs"), 0.5)
        );

        for (var action : actions) {
            String result = hitl.executeWithApproval(action,
                a -> "Executed " + a.name() + " with " + a.parameters(),
                "Agent step");
            System.out.println("  Result: " + result + "\n");
        }
    }
}

Explanation

The approval flow is a funnel:

Mermaid flowchart TD diagram

The pattern gates execution behind three checks:

  1. Risk classification: Each action is tagged as low, medium, or high risk. High-risk actions always require approval. Medium-risk actions only pause when confidence falls below the threshold, and low-risk ones auto-execute unless you configure it otherwise.
  2. Approval flow: when a pause is triggered, the agent presents the action details (name, parameters, confidence, reason) to a reviewer. From there the reviewer may approve it as-is, reject it outright, or tweak the parameters before it runs.
  3. Execution or abort: approved (or tweaked-then-approved) actions run. A rejection hands the agent feedback it can use to rework its plan.

The reviewer can be a human via CLI, a web UI, a Slack approval button, or even an automated secondary model for less critical decisions.

Variants

VariantDescriptionUse Case
Async approvalAgent pauses and resumes when approval arrives via webhookProduction agents with web UIs
Batch approvalPresent multiple actions at once for a single reviewReduces reviewer fatigue for routine workflows
Escalation chainRoute to senior reviewer if first reviewer rejectsTiered oversight for sensitive operations
Timeout auto-rejectIf no approval within N minutes, reject by defaultPrevents agents from hanging indefinitely

What Works

  • Classify risk conservatively; when in doubt, mark it high
  • Show the agent’s reasoning in the approval request, not just the action itself
  • Always set a timeout so agents never wait forever on a human
  • Log every approval decision; you need that trail for compliance and for tuning later
  • Let reviewers tweak parameters; often the action is right but the details are off
  • As the agent proves itself, lower the threshold and approve less

Common Mistakes

  • Requiring approval for every step, causing reviewer fatigue and slow execution
  • Not providing enough context for the reviewer to make an informed decision
  • No timeout, leaving agents stuck waiting for unresponsive reviewers
  • Not logging approvals, making audits impossible
  • Treating rejections as failures instead of feedback for the agent to adjust

Frequently Asked Questions

How do I determine the risk level of an action?

Ask yourself three things: is it reversible, does it touch external systems, does it cost money? A single "yes" and it counts as high risk. Read-only work is low risk; internal state changes with no external effect sit in between.

Can I use an LLM as the reviewer instead of a human?

For medium-risk work, sure: a second LLM with a different prompt can look over the first agent's plan, which keeps things fast without a human bottleneck. On high-risk work, the human gate stays no matter what.

What happens if the reviewer modifies the action?

It runs with whatever parameters the reviewer adjusted. Worth logging the diff so the agent can adapt its next steps; some implementations feed that back as a learning signal.

How do I implement async approval in a web app?

Persist the agent's state to storage and park it until someone responds. Fire off a notification (email, Slack, webhook) with an approval link; when the reviewer clicks through, resume the agent from the saved state with the decision.