Summary
Human-in-the-loop (HITL) patterns enable AI agents to request human approval, gather information, or escalate problems mid-execution. The key insight from 12 Factor Agents (Factor 7) is treating human contact as structured tool calls rather than plaintext. This article covers three HITL interaction types (approval, input, escalation), implementation patterns for each, channel-agnostic design, timing considerations, and integration with pause/resume infrastructure.
Why Human-in-the-Loop?
AI agents should not operate as fully autonomous systems for most production use cases. Humans provide:
- Judgment on irreversible actions – Deleting data, deploying to production, sending external communications
- Domain knowledge the agent lacks – Business context, customer preferences, compliance requirements
- Error correction before damage – Catching hallucinations or bad reasoning before execution
- Accountability – Clear audit trail of who approved what
The goal is not to slow down agents, but to build appropriate checkpoints where human oversight adds value without creating bottlenecks.
Three Types of Human Interaction
┌─────────────────────────────────────────────────────────────────┐
│ HUMAN-IN-THE-LOOP TYPES │
├─────────────────────────────────────────────────────────────────┤
│ │
│ ┌─────────────────┐ ┌─────────────────┐ ┌───────────────┐ │
│ │ APPROVAL │ │ INPUT │ │ ESCALATION │ │
│ │ │ │ │ │ │ │
│ │ "Can I deploy │ │ "Which email │ │ "I've failed │ │
│ │ to prod?" │ │ template?" │ │ 3 times..." │ │
│ │ │ │ │ │ │ │
│ │ Binary: Y/N │ │ Data: values │ │ Help: stuck │ │
│ └─────────────────┘ └─────────────────┘ └───────────────┘ │
│ │
│ Blocking: YES Blocking: YES Blocking: YES │
│ Timeout: Minutes Timeout: Hours Timeout: Days │
│ Auto-deny: Often Auto-deny: Rarely Auto-deny: No │
│ │
└─────────────────────────────────────────────────────────────────┘
Type 1: Approval
Binary yes/no decisions for specific actions. The agent knows what it wants to do and is asking permission.
Characteristics:
- Clear action proposed
- Risk-based decision
- Often time-sensitive
- Can auto-deny on timeout
Examples:
- Deploy to production
- Send email to 10,000 users
- Delete database records
- Create financial transaction
- Modify access permissions
Type 2: Input
Request for information or clarification. The agent lacks data needed to proceed.
Characteristics:
- Open-ended or structured response
- May include options
- Less time-sensitive
- Cannot auto-answer (needs real data)
Examples:
- “Which customer segment should this target?”
- “What tone should the email use?”
- “Which branch should I deploy?”
- “What’s the priority of this bug?”
Type 3: Escalation
Agent is stuck and needs help. This indicates a failure the agent cannot recover from autonomously.
Characteristics:
- Agent has already tried alternatives
- Context includes what failed
- May require investigation
- No timeout (needs resolution)
Examples:
- Three consecutive errors
- Unexpected system state
- Missing permissions
- Contradictory requirements
Implementation Patterns
Pattern 1: Human Contact as Tool Calls
From 12 Factor Agents (Factor 7): treat human interaction as structured tool calls, not plaintext. This enables auditing, multi-channel delivery, and consistent handling.
// Define human contact tools alongside regular tools
const humanTools = [
{
name: "request_approval",
description: "Request human approval before proceeding with a risky action",
parameters: {
action: { type: "string", description: "The action requiring approval" },
reason: { type: "string", description: "Why approval is needed" },
context: { type: "string", description: "Relevant context for decision" },
urgency: { type: "string", enum: ["low", "medium", "high", "critical"] },
timeout_minutes: { type: "number", description: "Auto-deny after N minutes" },
},
},
{
name: "request_input",
description: "Ask human for information needed to continue",
parameters: {
question: { type: "string", description: "The question to ask" },
options: {
type: "array",
items: { type: "string" },
description: "Suggested options (optional)",
},
required_format: {
type: "string",
description: "Expected format of response",
},
},
},
{
name: "escalate",
description: "Escalate to human when stuck or encountering unexpected issues",
parameters: {
problem: { type: "string", description: "What went wrong" },
attempts: {
type: "array",
items: { type: "string" },
description: "What was already tried",
},
suggestion: { type: "string", description: "Suggested resolution if any" },
},
},
];
Pattern 2: Channel-Agnostic Notification
Decouple the decision to contact a human from the delivery mechanism. The same approval request might go to Slack, email, SMS, or a web dashboard depending on urgency and user preferences.
interface HumanNotification {
type: "approval" | "input" | "escalation";
threadId: string;
payload: Record<string, unknown>;
urgency: "low" | "medium" | "high" | "critical";
channels: string[]; // Preferred channels in order
timeout?: number; // Minutes
fallbackAction?: "deny" | "skip" | "none";
}
class NotificationRouter {
private channels: Map<string, NotificationChannel> = new Map();
async notify(notification: HumanNotification): Promise<void> {
// Try channels in preference order
for (const channelName of notification.channels) {
const channel = this.channels.get(channelName);
if (!channel) continue;
try {
await channel.send({
title: this.formatTitle(notification),
body: this.formatBody(notification),
actions: this.getActions(notification),
callbackUrl: `/api/respond/${notification.threadId}`,
});
// Log which channel was used
await this.logNotification(notification, channelName);
return;
} catch (error) {
// Channel failed, try next
continue;
}
}
// All channels failed
throw new Error(`Failed to deliver notification via any channel`);
}
private getActions(notification: HumanNotification): Action[] {
switch (notification.type) {
case "approval":
return [
{ label: "Approve", value: "approved", style: "primary" },
{ label: "Deny", value: "denied", style: "danger" },
];
case "input":
return [{ label: "Respond", value: "respond", style: "primary" }];
case "escalation":
return [
{ label: "Take Over", value: "takeover", style: "primary" },
{ label: "Provide Guidance", value: "guide", style: "secondary" },
];
}
}
}
Pattern 3: Pause-Resume Integration
Human-in-the-loop requires robust pause/resume infrastructure. The agent must persist state, exit cleanly, and resume exactly where it left off.
class HITLAgent {
private db: ThreadStore;
private notifier: NotificationRouter;
async run(thread: AgentThread): Promise<AgentThread> {
while (thread.status === "running") {
const toolCall = await this.llm.getNextAction(thread);
if (this.isHumanTool(toolCall)) {
return await this.handleHumanTool(thread, toolCall);
}
// Regular tool execution
const result = await this.executeTool(toolCall);
thread.events.push({
type: "tool_result",
tool: toolCall.name,
result,
timestamp: new Date(),
});
await this.db.saveThread(thread);
}
return thread;
}
private async handleHumanTool(
thread: AgentThread,
toolCall: ToolCall
): Promise<AgentThread> {
// 1. Record the request
thread.events.push({
type: "human_contact_requested",
toolCall,
timestamp: new Date(),
});
// 2. Pause the thread
thread.status = "paused";
await this.db.saveThread(thread);
// 3. Send notification
await this.notifier.notify({
type: toolCall.name.replace("request_", "") as any,
threadId: thread.id,
payload: toolCall.params,
urgency: toolCall.params.urgency || "medium",
channels: this.getChannelsForThread(thread),
timeout: toolCall.params.timeout_minutes,
fallbackAction: toolCall.name === "request_approval" ? "deny" : "none",
});
// 4. Return paused thread (execution stops here)
return thread;
}
async handleHumanResponse(
threadId: string,
response: HumanResponse
): Promise<AgentThread> {
const thread = await this.db.getThread(threadId);
// Record the response
thread.events.push({
type: "human_response",
response,
timestamp: new Date(),
});
// Resume execution
thread.status = "running";
return this.run(thread);
}
}
Pattern 4: Webhook Callback Handler
External systems (Slack, email links, dashboards) need a way to send responses back to the agent.
// Express webhook handler
app.post("/api/respond/:threadId", async (req, res) => {
const { threadId } = req.params;
const { action, value, responderId, signature } = req.body;
// 1. Verify signature (prevent spoofing)
if (!verifySignature(req.body, signature)) {
return res.status(401).json({ error: "Invalid signature" });
}
// 2. Load thread and validate state
const thread = await db.getThread(threadId);
if (!thread) {
return res.status(404).json({ error: "Thread not found" });
}
if (thread.status !== "paused") {
return res.status(409).json({ error: "Thread not awaiting response" });
}
// 3. Record response
const response: HumanResponse = {
action,
value,
responderId,
timestamp: new Date(),
};
// 4. Resume agent (in background)
agent.handleHumanResponse(threadId, response).catch((err) => {
logger.error("Failed to resume agent", { threadId, error: err });
});
// 5. Return immediately
res.json({ status: "processing", threadId });
});
Timing and Timeout Strategies
Urgency-Based Routing
Different urgency levels warrant different channels and timeouts:
const urgencyConfig = {
low: {
channels: ["email", "slack"],
timeout: 24 * 60, // 24 hours
reminder: 8 * 60, // Remind after 8 hours
fallback: "deny",
},
medium: {
channels: ["slack", "email"],
timeout: 4 * 60, // 4 hours
reminder: 1 * 60, // Remind after 1 hour
fallback: "deny",
},
high: {
channels: ["slack", "sms"],
timeout: 30, // 30 minutes
reminder: 10, // Remind after 10 minutes
fallback: "escalate",
},
critical: {
channels: ["sms", "slack", "pagerduty"],
timeout: 5, // 5 minutes
reminder: 2, // Remind after 2 minutes
fallback: "escalate",
},
};
Auto-Timeout Handling
When humans do not respond in time:
async function handleTimeout(threadId: string): Promise<void> {
const thread = await db.getThread(threadId);
const pendingRequest = findPendingHumanRequest(thread);
if (!pendingRequest) return;
const config = urgencyConfig[pendingRequest.urgency];
switch (config.fallback) {
case "deny":
// Auto-deny the requested action
thread.events.push({
type: "human_response",
response: {
action: "denied",
value: null,
responderId: "system:timeout",
timestamp: new Date(),
},
});
thread.status = "running";
await agent.run(thread);
break;
case "escalate":
// Escalate to higher authority
await notifier.escalate({
threadId,
reason: "timeout",
originalRequest: pendingRequest,
});
break;
case "skip":
// Skip the action and continue
thread.events.push({
type: "action_skipped",
reason: "timeout",
timestamp: new Date(),
});
thread.status = "running";
await agent.run(thread);
break;
}
}
Context for Human Reviewers
Humans making approval decisions need sufficient context. Structure the notification to include:
function buildApprovalContext(thread: AgentThread, toolCall: ToolCall): string {
return `
## Approval Request
**Action:** ${toolCall.params.action}
**Reason:** ${toolCall.params.reason}
### Context
${toolCall.params.context}
### What Happened Before
${summarizeRecentEvents(thread.events, 5)}
### What Will Happen Next
${describeConsequences(toolCall)}
### Thread Info
- Thread ID: ${thread.id}
- Started: ${thread.events[0].timestamp}
- Current Step: ${countSteps(thread.events)}
- Previous Approvals: ${countApprovals(thread.events)}
`;
}
When to Require Human Involvement
Always Require Approval
- Irreversible destructive actions (delete, drop, revoke)
- External communications (email campaigns, notifications)
- Financial transactions above threshold
- Access control changes
- Production deployments
Consider Auto-Approval
- Read-only operations
- Staging/dev environment changes
- Actions within established patterns
- Low-risk reversible changes
- After sufficient trust is built
Risk-Based Thresholds
function requiresApproval(toolCall: ToolCall, context: AgentContext): boolean {
// Always require for dangerous tools
if (DANGEROUS_TOOLS.includes(toolCall.name)) {
return true;
}
// Check amount thresholds
if (toolCall.params.amount && toolCall.params.amount > 1000) {
return true;
}
// Check recipient count
if (toolCall.params.recipients?.length > 100) {
return true;
}
// Trust earned agents less scrutiny
if (context.agentTrustLevel === "high" && !toolCall.params.production) {
return false;
}
// Default to approval for new agents
return context.agentTrustLevel === "new";
}
Common Pitfalls
Pitfall 1: Blocking on Approval Synchronously
// Bad: Process blocks waiting indefinitely
const approval = await waitForApproval(toolCall); // Never do this
// Good: Persist state and exit, webhook resumes
await requestApproval(toolCall);
thread.status = "paused";
await db.saveThread(thread);
return thread; // Process exits cleanly
Pitfall 2: Insufficient Context for Reviewers
// Bad: Human has no idea what this means
await requestApproval({
action: "execute_query",
urgency: "high",
});
// Good: Full context for informed decision
await requestApproval({
action: "Delete 2,847 user records matching inactive > 2 years",
reason: "GDPR compliance requirement for data retention",
context: "This is part of the quarterly data cleanup. Records were identified by the compliance team. A backup will be created before deletion.",
urgency: "medium",
reversible: false,
});
Pitfall 3: Too Many Approval Requests
// Bad: Approval fatigue leads to rubber-stamping
for (const record of records) {
await requestApproval({ action: `Delete record ${record.id}` });
await deleteRecord(record);
}
// Good: Batch into single meaningful approval
const summary = `Delete ${records.length} records (IDs: ${records.slice(0, 5).map(r => r.id).join(", ")}...)`;
await requestApproval({
action: summary,
context: buildBatchContext(records),
});
// Then delete all after single approval
Pitfall 4: No Audit Trail
// Bad: Approvals not recorded
if (humanSaidYes) {
await executeDangerousAction();
}
// Good: Full audit trail in event log
thread.events.push({
type: "approval_granted",
approver: response.responderId,
action: toolCall.params.action,
timestamp: new Date(),
});
await executeDangerousAction();
thread.events.push({
type: "action_executed",
action: toolCall.params.action,
timestamp: new Date(),
});
Best Practices
- Batch similar requests – One approval for 100 deletions, not 100 approvals
- Provide rich context – What, why, consequences, what already happened
- Set appropriate timeouts – Match urgency to business needs
- Log everything – Who approved what, when, via which channel
- Test the unhappy path – Denials, timeouts, escalations
- Progressive trust – Reduce approval requirements as agent proves reliable
- Multi-channel fallback – Slack fails? Try email. Email fails? Try SMS.
Related
- 12 Factor Agents – Factor 7: Contact Humans with Tool Calls
- Agent Memory Patterns – Checkpoint/resume infrastructure for HITL
- Event Sourcing for Agents – Audit trail for approvals
- Trust But Verify Protocol – When to require verification
- YOLO Mode Configuration – When to skip approvals safely
- AI Workflow Notifications – Multi-channel notification patterns
References
- HumanLayer: 12 Factor Agents – Factor 7: Contact Humans with Tool Calls
- Anthropic: Human in the Loop – Official guidance on human oversight
- Slack Block Kit – Building interactive approval messages

