Academy15 Aug 202511 min read

Human-in-the-Loop: Building Approval Workflows for AI Agents

Design approval workflows that pause agent execution for human review on sensitive operations -sending emails, deleting data, making purchases -with async approval queues and timeout handling.

MB
Max Beech
Head of Content

TL;DR

  • Require human approval for destructive operations (delete data, send emails, make purchases) before agent execution.
  • Store approval requests in database queue with agent state snapshot for resumption.
  • Support three actions: approve (execute as planned), reject (cancel), modify (change parameters then execute).
  • Implement timeouts: auto-reject or escalate if no response within SLA.

Jump to Approval workflow design · Jump to Implementation patterns · Jump to User interface · Jump to Timeout handling

Human-in-the-Loop: Building Approval Workflows for AI Agents

Autonomous agents are powerful but risky. An agent with email access might send 1,000 outreach messages to your entire customer list by mistake. One with database access might delete production data. Human-in-the-loop (HITL) approval workflows insert safety gates: agents plan actions, request approval, wait for human decision, then execute only if approved.

This guide covers building approval workflows for AI agents, based on Athenic's implementation where we process 400+ approval requests weekly with 94% approval rate and <10 minute median response time.

Key takeaways

  • Define approval rules based on operation risk and impact scope.
  • Store approval state in database to survive server restarts and scale across instances.
  • Give users three options: approve unchanged, reject, or modify parameters before executing.
  • Set timeouts (30 min - 24 hours) with escalation policies to prevent stuck workflows.

Approval workflow design

When to require approval

Not every agent action needs approval. Reserve it for high-risk operations.

OperationRisk levelApproval required?Rationale
Read database recordLowNoNo destructive impact
Search webLowNoExternal, read-only
Send single emailMediumYesReputation risk if poorly worded
Send bulk emails (>10)HighAlwaysHigh spam/reputation risk
Delete dataHighAlwaysIrreversible
Make purchaseHighAlwaysFinancial impact
Modify user-facing contentMediumConditionalBrand risk if public

Conditional approval: Require approval if operation affects >N users or costs >$X.

function requiresApproval(operation: Operation): boolean {
  const alwaysApprove = ['delete_data', 'send_bulk_email', 'make_purchase'];

  if (alwaysApprove.includes(operation.type)) return true;

  // Conditional rules
  if (operation.type === 'send_email' && operation.params.recipients.length > 10) {
    return true;
  }

  if (operation.type === 'modify_content' && operation.params.visibility === 'public') {
    return true;
  }

  return false;
}

Approval request schema

Store approval requests with enough context for humans to make informed decisions.

CREATE TABLE approval_requests (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  org_id TEXT NOT NULL,
  requested_by_agent TEXT NOT NULL,
  operation_type TEXT NOT NULL,
  operation_params JSONB NOT NULL,
  justification TEXT, -- Agent's reason for this operation
  status TEXT NOT NULL DEFAULT 'pending', -- 'pending', 'approved', 'rejected', 'timeout'
  requested_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
  responded_at TIMESTAMPTZ,
  responded_by_user_id UUID REFERENCES users(id),
  response_notes TEXT,
  timeout_at TIMESTAMPTZ NOT NULL,
  agent_state JSONB -- Snapshot for resuming after approval
);

CREATE INDEX idx_approvals_org_status ON approval_requests(org_id, status, requested_at);

Agent state snapshot

When agent pauses for approval, save its state to resume later.

interface AgentState {
  trace_id: string;
  session_id: string;
  current_step: number;
  context: Record<string, any>;
  pending_tool_calls: Array<{
    tool: string;
    params: any;
  }>;
}

async function requestApproval(
  operation: string,
  params: any,
  justification: string,
  agentState: AgentState,
): Promise<string> {
  const timeoutAt = new Date(Date.now() + 4 * 60 * 60 * 1000); // 4 hours

  const approval = await db.approvalRequests.insert({
    org_id: agentState.context.org_id,
    requested_by_agent: agentState.context.agent_name,
    operation_type: operation,
    operation_params: params,
    justification,
    timeout_at: timeoutAt,
    agent_state: agentState,
  });

  // Notify user
  await notifyApprovalNeeded(approval.id, approval.org_id);

  return approval.id;
}

Implementation patterns

Synchronous wait pattern (simple but limited)

Agent blocks waiting for approval response.

async function executeWithApproval(operation: string, params: any) {
  if (requiresApproval({ type: operation, params })) {
    const approvalId = await requestApproval(operation, params, 'User requested this action', agentState);

    // Poll for approval (blocks execution)
    const approval = await waitForApproval(approvalId, { timeout: 3600000 }); // 1 hour

    if (approval.status === 'approved') {
      return await executeOperation(operation, approval.operation_params); // May be modified
    } else {
      throw new Error(`Operation rejected: ${approval.response_notes}`);
    }
  } else {
    return await executeOperation(operation, params);
  }
}

async function waitForApproval(approvalId: string, options: { timeout: number }): Promise<Approval> {
  const startTime = Date.now();

  while (Date.now() - startTime < options.timeout) {
    const approval = await db.approvalRequests.findOne({ id: approvalId });

    if (approval.status !== 'pending') {
      return approval;
    }

    await sleep(5000); // Poll every 5s
  }

  throw new Error('Approval timeout');
}

Pros: Simple to implement Cons: Ties up agent process, doesn't scale, fails on server restart

Async queue pattern (production-ready)

Agent creates approval request, exits, and is resumed when approval comes through.

// Step 1: Agent requests approval and exits
async function requestApprovalAsync(operation: string, params: any, agentState: AgentState) {
  const approvalId = await requestApproval(operation, params, 'Agent planned this action', agentState);

  // Exit agent, approval will resume it later
  return { status: 'pending_approval', approval_id: approvalId };
}

// Step 2: User approves via API/UI
app.post('/api/approvals/:id/approve', async (req, res) => {
  const { id } = req.params;
  const { modifications } = req.body; // Optional param changes

  await db.approvalRequests.update(id, {
    status: 'approved',
    responded_at: new Date(),
    responded_by_user_id: req.user.id,
    operation_params: modifications || undefined, // Override if modified
  });

  // Trigger agent resumption
  await resumeAgent(id);

  res.json({ success: true });
});

// Step 3: Resume agent from stored state
async function resumeAgent(approvalId: string) {
  const approval = await db.approvalRequests.findOne({ id: approvalId });

  if (approval.status !== 'approved') return;

  const agentState: AgentState = approval.agent_state;
  const params = approval.operation_params; // May have been modified by user

  // Execute approved operation
  const result = await executeOperation(approval.operation_type, params);

  // Resume agent workflow
  await continueAgentExecution(agentState, result);
}

Pros: Scales horizontally, survives restarts, no blocking Cons: More complex state management

At Athenic, we use the async queue pattern for all approvals.

User interface

Approval UI should show context clearly and allow approve/reject/modify actions.

Approval card components

interface ApprovalCardProps {
  approval: ApprovalRequest;
  onApprove: () => void;
  onReject: (reason: string) => void;
  onModify: (newParams: any) => void;
}

function ApprovalCard({ approval, onApprove, onReject, onModify }: ApprovalCardProps) {
  const [isModifying, setIsModifying] = useState(false);
  const [modifiedParams, setModifiedParams] = useState(approval.operation_params);

  return (
    <div className="approval-card">
      <div className="header">
        <span className="agent-name">{approval.requested_by_agent}</span>
        <span className="operation-type">{approval.operation_type}</span>
        <span className="time-ago">{formatTimeAgo(approval.requested_at)}</span>
      </div>

      <div className="justification">
        {approval.justification}
      </div>

      <div className="operation-details">
        <h4>Planned action:</h4>
        {renderOperationPreview(approval.operation_type, approval.operation_params)}
      </div>

      {isModifying ? (
        <div className="modify-params">
          <JSONEditor
            value={modifiedParams}
            onChange={setModifiedParams}
          />
          <button onClick={() => onModify(modifiedParams)}>
            Approve with changes
          </button>
          <button onClick={() => setIsModifying(false)}>Cancel</button>
        </div>
      ) : (
        <div className="actions">
          <button onClick={onApprove} className="approve">
            Approve
          </button>
          <button onClick={() => setIsModifying(true)} className="modify">
            Modify
          </button>
          <button onClick={() => {
            const reason = prompt('Rejection reason:');
            if (reason) onReject(reason);
          }} className="reject">
            Reject
          </button>
        </div>
      )}

      <div className="timeout-warning">
        Auto-rejects in {formatDuration(approval.timeout_at)}
      </div>
    </div>
  );
}

Operation preview rendering

Show users what the operation will do in plain language.

function renderOperationPreview(type: string, params: any) {
  switch (type) {
    case 'send_email':
      return (
        <div>
          <p><strong>To:</strong> {params.recipients.join(', ')}</p>
          <p><strong>Subject:</strong> {params.subject}</p>
          <div className="email-body">{params.body}</div>
        </div>
      );

    case 'delete_data':
      return (
        <div className="warning">
          <p><strong>⚠️  This will permanently delete:</strong></p>
          <p>{params.table}: {params.record_count} records</p>
          <p>Matching filter: <code>{JSON.stringify(params.filter)}</code></p>
        </div>
      );

    case 'make_purchase':
      return (
        <div>
          <p><strong>Service:</strong> {params.service}</p>
          <p><strong>Amount:</strong> ${params.amount_usd}</p>
          <p><strong>Description:</strong> {params.description}</p>
        </div>
      );

    default:
      return <pre>{JSON.stringify(params, null, 2)}</pre>;
  }
}

Real-time notifications

Notify users instantly when approval is needed.

// Backend: publish approval request via Supabase Realtime
await supabase
  .from('approval_requests')
  .insert(approvalRequest)
  .then(() => {
    supabase.channel('approvals').send({
      type: 'broadcast',
      event: 'new_approval',
      payload: { approval_id: approvalRequest.id, org_id: approvalRequest.org_id },
    });
  });

// Frontend: subscribe and show notification
const channel = supabase.channel('approvals');

channel.on('broadcast', { event: 'new_approval' }, (payload) => {
  if (payload.org_id === currentUser.org_id) {
    showNotification('New approval request', {
      body: 'Agent needs approval for an action',
      onClick: () => navigate(`/approvals/${payload.approval_id}`),
    });
  }
}).subscribe();

Timeout handling

Approvals can't wait forever. Set timeouts with escalation policies.

Timeout strategies

StrategyWhen to useImplementation
Auto-rejectHigh-risk operationsReject after timeout, notify user
Auto-approveLow-risk with time sensitivityApprove after timeout if no response
EscalateMission-critical workflowsNotify manager/admin after initial timeout
Extend deadlineUser requested more timeAllow one-time deadline extension
// Timeout worker (runs every 5 minutes)
cron.schedule('*/5 * * * *', async () => {
  const timedOut = await db.approvalRequests.findAll({
    status: 'pending',
    timeout_at: { $lte: new Date() },
  });

  for (const approval of timedOut) {
    const strategy = getTimeoutStrategy(approval.operation_type);

    if (strategy === 'auto-reject') {
      await db.approvalRequests.update(approval.id, {
        status: 'rejected',
        response_notes: 'Auto-rejected due to timeout',
        responded_at: new Date(),
      });

      await notifyTimeout(approval, 'rejected');
    } else if (strategy === 'escalate') {
      await escalateApproval(approval);
    }
  }
});

function getTimeoutStrategy(operationType: string): string {
  const highRisk = ['delete_data', 'make_purchase', 'send_bulk_email'];

  return highRisk.includes(operationType) ? 'auto-reject' : 'escalate';
}

async function escalateApproval(approval: ApprovalRequest) {
  // Extend timeout and notify manager
  await db.approvalRequests.update(approval.id, {
    timeout_at: new Date(Date.now() + 4 * 60 * 60 * 1000), // +4 hours
  });

  const manager = await getOrgManager(approval.org_id);

  await sendEmail(manager.email, 'approval_escalation', {
    approval_id: approval.id,
    operation: approval.operation_type,
    original_timeout: approval.timeout_at,
  });
}

Real-world case study: Athenic approval system

We process 400+ approval requests weekly across our partnership, research, and content agents.

Approval statistics:

  • 94% approval rate
  • 6% rejection rate
  • <1% timeout rate
  • Median response time: 8 minutes
  • p95 response time: 2.4 hours

Most common operations:

OperationWeekly volumeApproval rateAvg response time
Send outreach email18097%4 min
Delete lead (duplicate)8592%12 min
Modify database record6591%18 min
Purchase data credits4088%45 min
Send bulk email campaign3083%2.1 hours

Key learnings:

  1. Modification rate: 22% of approvals are modified before execution (email subject changes, recipient list adjustments)
  2. Time sensitivity: 68% of approvals happen within 15 minutes; after 4 hours, approval rate drops to 45%
  3. Mobile approvals: 38% of approvals come from mobile devices -UI must be mobile-friendly

Example workflow: partnership outreach email

  1. Agent drafts outreach email to 12 qualified leads
  2. Creates approval request with email preview
  3. Pushes real-time notification to partnership lead
  4. Human reviews, modifies subject line ("Exploring partnership" → "Quick question about your API")
  5. Approves modified version
  6. Agent sends 12 personalized emails with updated subject
  7. Total time: 6 minutes from draft to send

Without approval, agent would have sent original (less effective) subject line to all recipients.

Call-to-action (Activation stage) Clone our approval workflow starter kit with database schema, API routes, and React components.

FAQs

Should I allow batch approvals?

Yes for similar operations (10 identical emails with different recipients). No for diverse operations -forces users to review each carefully.

How do I handle approvals across timezones?

Set timeout duration based on user timezone (longer timeouts for requests outside business hours) or escalate to users in active timezones.

Can agents learn from approval patterns?

Yes. Track which operations get rejected and why. Adjust agent prompts to avoid known patterns (e.g., "Don't use generic subject lines in outreach emails").

What if user approves a harmful operation?

Log all approvals with user ID for audit trail. Implement secondary safeguards (rate limits, cost caps) that prevent catastrophic damage even after approval.

How do I test approval workflows?

Use test mode with instant auto-approval or mock approval responses. Don't test with production approval queues -creates noise for real users.

Summary and next steps

Human-in-the-loop approval workflows insert safety gates before high-risk agent operations. Store approval requests in async queues with agent state snapshots, build UI with approve/reject/modify actions, implement timeouts with escalation policies, and notify users in real-time.

Next steps:

  1. Define which operations require approval based on risk and impact.
  2. Implement approval request database schema with agent state storage.
  3. Build async queue pattern with agent resumption logic.
  4. Create approval UI with operation previews and modification support.
  5. Set up timeout handling with appropriate escalation policies.

Internal links:

External references:

Crosslinks: