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.
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:
The pattern gates execution behind three checks:
- 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.
- 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.
- 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
| Variant | Description | Use Case |
|---|---|---|
| Async approval | Agent pauses and resumes when approval arrives via webhook | Production agents with web UIs |
| Batch approval | Present multiple actions at once for a single review | Reduces reviewer fatigue for routine workflows |
| Escalation chain | Route to senior reviewer if first reviewer rejects | Tiered oversight for sensitive operations |
| Timeout auto-reject | If no approval within N minutes, reject by default | Prevents 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.
Related Resources
LLM Guardrails Pattern
Validate LLM inputs and outputs with rules, classifiers, and content filters. Prevent prompt injection, toxic content, and data leakage before reaching users.
PatternAgent Tool Selection Pattern
Dynamically select which tools an LLM agent can use based on the task context. Reduce token usage and improve decision quality by narrowing the tool set.
RecipeBuild Stateful AI Agents with LangGraph State Machines
Create multi-step AI agents with LangGraph using state machines, conditional edges, tool calling, and human-in-the-loop checkpoints for production workflows