Human-in-the-Loop Patterns: Approval, Input, and Escalation Workflows

James Phoenix
James Phoenix

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:

  1. Judgment on irreversible actions – Deleting data, deploying to production, sending external communications
  2. Domain knowledge the agent lacks – Business context, customer preferences, compliance requirements
  3. Error correction before damage – Catching hallucinations or bad reasoning before execution
  4. 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.

Udemy Bestseller

Learn Prompt Engineering

My O'Reilly book adapted for hands-on learning. Build production-ready prompts with practical exercises.

4.5/5 rating
306,000+ learners
View Course

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

  1. Batch similar requests – One approval for 100 deletions, not 100 approvals
  2. Provide rich context – What, why, consequences, what already happened
  3. Set appropriate timeouts – Match urgency to business needs
  4. Log everything – Who approved what, when, via which channel
  5. Test the unhappy path – Denials, timeouts, escalations
  6. Progressive trust – Reduce approval requirements as agent proves reliable
  7. Multi-channel fallback – Slack fails? Try email. Email fails? Try SMS.

Related

References

Topics
12 Factor AgentsAgent ArchitectureApproval WorkflowsEscalationHITLHuman Ai CollaborationHuman In The LoopMulti ChannelPause ResumeWebhooks

More Insights

Cover Image for Own Your Control Plane

Own Your Control Plane

If you use someone else’s task manager, you inherit all of their abstractions. In a world where LLMs make software a solved problem, the cost of ownership has flipped.

James Phoenix
James Phoenix
Cover Image for Indexed PRD and Design Doc Strategy

Indexed PRD and Design Doc Strategy

A documentation-driven development pattern where a single `index.md` links all PRDs and design documents, creating navigable context for both humans and AI agents.

James Phoenix
James Phoenix