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.
| Operation | Risk level | Approval required? | Rationale |
|---|
| Read database record | Low | No | No destructive impact |
| Search web | Low | No | External, read-only |
| Send single email | Medium | Yes | Reputation risk if poorly worded |
| Send bulk emails (>10) | High | Always | High spam/reputation risk |
| Delete data | High | Always | Irreversible |
| Make purchase | High | Always | Financial impact |
| Modify user-facing content | Medium | Conditional | Brand 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
| Strategy | When to use | Implementation |
|---|
| Auto-reject | High-risk operations | Reject after timeout, notify user |
| Auto-approve | Low-risk with time sensitivity | Approve after timeout if no response |
| Escalate | Mission-critical workflows | Notify manager/admin after initial timeout |
| Extend deadline | User requested more time | Allow 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:
| Operation | Weekly volume | Approval rate | Avg response time |
|---|
| Send outreach email | 180 | 97% | 4 min |
| Delete lead (duplicate) | 85 | 92% | 12 min |
| Modify database record | 65 | 91% | 18 min |
| Purchase data credits | 40 | 88% | 45 min |
| Send bulk email campaign | 30 | 83% | 2.1 hours |
Key learnings:
- Modification rate: 22% of approvals are modified before execution (email subject changes, recipient list adjustments)
- Time sensitivity: 68% of approvals happen within 15 minutes; after 4 hours, approval rate drops to 45%
- Mobile approvals: 38% of approvals come from mobile devices -UI must be mobile-friendly
Example workflow: partnership outreach email
- Agent drafts outreach email to 12 qualified leads
- Creates approval request with email preview
- Pushes real-time notification to partnership lead
- Human reviews, modifies subject line ("Exploring partnership" → "Quick question about your API")
- Approves modified version
- Agent sends 12 personalized emails with updated subject
- 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:
- Define which operations require approval based on risk and impact.
- Implement approval request database schema with agent state storage.
- Build async queue pattern with agent resumption logic.
- Create approval UI with operation previews and modification support.
- Set up timeout handling with appropriate escalation policies.
Internal links:
External references:
Crosslinks: