By Sagar Shankaran, Founder of CallSphere
Build an AI agent with human approval gates for sensitive actions, including notification systems, configurable timeout handling, delegation chains, and audit logging for compliance.
Key takeaways
Autonomous AI agents are powerful, but certain actions carry real-world consequences that demand human oversight. Sending an email to a customer, executing a financial transaction, deploying code to production, or deleting records — these actions should not happen without explicit human approval.
An approval workflow inserts a pause point between the agent deciding to act and the action actually executing. The agent proposes the action, a human reviews it, and only after approval does execution proceed.
The core abstraction is an approval request that captures the proposed action, who requested it, who can approve it, and the current status:
flowchart LR
INPUT(["User intent"])
PARSE["Parse plus<br/>classify"]
PLAN["Plan and tool<br/>selection"]
AGENT["Agent loop<br/>LLM plus tools"]
GUARD{"Guardrails<br/>and policy"}
EXEC["Execute and<br/>verify result"]
OBS[("Trace and metrics")]
OUT(["Outcome plus<br/>next action"])
INPUT --> PARSE --> PLAN --> AGENT --> GUARD
GUARD -->|Pass| EXEC --> OUT
GUARD -->|Fail| AGENT
AGENT --> OBS
style AGENT fill:#4f46e5,stroke:#4338ca,color:#fff
style GUARD fill:#f59e0b,stroke:#d97706,color:#1f2937
style OBS fill:#ede9fe,stroke:#7c3aed,color:#1e1b4b
style OUT fill:#059669,stroke:#047857,color:#fff
import uuid
import asyncio
from datetime import datetime, timezone, timedelta
from dataclasses import dataclass, field
from enum import Enum
from typing import Any
class ApprovalStatus(Enum):
PENDING = "pending"
APPROVED = "approved"
REJECTED = "rejected"
TIMED_OUT = "timed_out"
DELEGATED = "delegated"
@dataclass
class ApprovalRequest:
request_id: str = field(default_factory=lambda: str(uuid.uuid4()))
action_type: str = ""
action_params: dict[str, Any] = field(default_factory=dict)
requested_by: str = ""
approvers: list[str] = field(default_factory=list)
status: ApprovalStatus = ApprovalStatus.PENDING
created_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc))
timeout_minutes: int = 30
decision_by: str = ""
decision_reason: str = ""
decided_at: datetime | None = None
The approval manager handles the lifecycle of requests — creating them, waiting for decisions, and enforcing timeouts:
Hear it before you finish reading
Talk to a live CallSphere AI voice agent in your browser — 60 seconds, no signup.
class ApprovalManager:
def __init__(self):
self.requests: dict[str, ApprovalRequest] = {}
self._waiters: dict[str, asyncio.Event] = {}
async def request_approval(
self,
action_type: str,
action_params: dict,
approvers: list[str],
timeout_minutes: int = 30,
) -> ApprovalRequest:
"""Create an approval request and wait for a decision."""
req = ApprovalRequest(
action_type=action_type,
action_params=action_params,
approvers=approvers,
timeout_minutes=timeout_minutes,
)
self.requests[req.request_id] = req
self._waiters[req.request_id] = asyncio.Event()
# Notify approvers
await self._notify_approvers(req)
# Wait for decision or timeout
try:
await asyncio.wait_for(
self._waiters[req.request_id].wait(),
timeout=timeout_minutes * 60,
)
except asyncio.TimeoutError:
req.status = ApprovalStatus.TIMED_OUT
return req
def submit_decision(
self,
request_id: str,
approved: bool,
decided_by: str,
reason: str = "",
):
"""An approver submits their decision."""
req = self.requests.get(request_id)
if not req or req.status != ApprovalStatus.PENDING:
raise ValueError("Invalid or already decided request")
if decided_by not in req.approvers:
raise PermissionError(f"{decided_by} is not an authorized approver")
req.status = ApprovalStatus.APPROVED if approved else ApprovalStatus.REJECTED
req.decision_by = decided_by
req.decision_reason = reason
req.decided_at = datetime.now(timezone.utc)
# Wake up the waiting coroutine
self._waiters[request_id].set()
async def _notify_approvers(self, req: ApprovalRequest):
"""Send notifications to all potential approvers."""
for approver in req.approvers:
await send_notification(
to=approver,
subject=f"Approval needed: {req.action_type}",
body=f"Action: {req.action_type}\nParams: {req.action_params}",
)
The key design choice is using asyncio.Event and asyncio.wait_for. The agent's coroutine suspends without consuming resources while waiting for a human decision. If the timeout expires, the request is automatically marked as timed out.
Wrap your agent's sensitive actions with approval checks:
class ApprovalGatedAgent:
SENSITIVE_ACTIONS = {"send_email", "delete_record", "execute_payment"}
def __init__(self, approval_manager: ApprovalManager):
self.approvals = approval_manager
async def execute_action(self, action: str, params: dict) -> dict:
if action not in self.SENSITIVE_ACTIONS:
return await self._run_action(action, params)
# Request approval for sensitive actions
req = await self.approvals.request_approval(
action_type=action,
action_params=params,
approvers=["manager@company.com", "lead@company.com"],
timeout_minutes=60,
)
if req.status == ApprovalStatus.APPROVED:
result = await self._run_action(action, params)
return {"status": "executed", "approved_by": req.decision_by, **result}
elif req.status == ApprovalStatus.REJECTED:
return {"status": "rejected", "reason": req.decision_reason}
else:
return {"status": "timed_out", "message": "No approver responded"}
async def _run_action(self, action: str, params: dict) -> dict:
# Dispatch to actual implementation
handler = getattr(self, f"_action_{action}", None)
if handler:
return await handler(params)
return {"error": f"Unknown action: {action}"}
Sometimes the initial approver is unavailable. Delegation allows an approver to forward the request:
def delegate_approval(
self,
request_id: str,
delegated_by: str,
delegate_to: str,
):
"""Delegate an approval request to another person."""
req = self.requests.get(request_id)
if not req or req.status != ApprovalStatus.PENDING:
raise ValueError("Cannot delegate a non-pending request")
if delegated_by not in req.approvers:
raise PermissionError("Not an authorized approver")
# Add the delegate and record the chain
req.approvers.append(delegate_to)
req.action_params.setdefault("delegation_chain", [])
req.action_params["delegation_chain"].append({
"from": delegated_by,
"to": delegate_to,
"at": datetime.now(timezone.utc).isoformat(),
})
Notifications can use any channel — email, Slack, or a web dashboard. Here is a simple multi-channel notifier:
Still reading? Stop comparing — try CallSphere live.
CallSphere ships complete AI voice agents per industry — 14 tools for healthcare, 10 agents for real estate, 4 specialists for salons. See how it actually handles a call before you book a demo.
async def send_notification(to: str, subject: str, body: str):
"""Route notifications based on recipient preferences."""
channel = get_preferred_channel(to)
if channel == "slack":
await post_slack_message(to, f"*{subject}*\n{body}")
elif channel == "email":
await send_email(to, subject, body)
elif channel == "webhook":
await post_webhook(to, {"subject": subject, "body": body})
Enforce approval at the execution layer, not the agent layer. The tool implementations themselves should check for valid approval tokens before executing. Even if the agent skips the approval call, the underlying send_email or execute_payment function refuses to run without a signed approval reference.
It depends on the action's urgency. For customer-facing emails, 30 to 60 minutes is reasonable. For financial transactions, you might want 15 minutes with escalation. For non-urgent batch operations, 24 hours with a daily digest notification works well. Always provide a fallback behavior — either auto-reject on timeout or escalate to a backup approver.
Implement an escalation policy with timezone awareness. If the primary approver is outside business hours, route to an on-call approver or queue the request with a "next business day" timeout. For critical actions, maintain a rotating on-call schedule of approvers who receive notifications regardless of time.
#HumanintheLoop #ApprovalWorkflow #AgentSafety #AgenticAI #Python #LearnAI #AIEngineering

Written by
Sagar Shankaran· Founder, CallSphere
LinkedInSagar Shankaran is the founder of CallSphere, where he builds production AI voice and chat agents deployed across healthcare, hospitality, real estate, and home services. He writes about agentic AI, LLM engineering, and shipping voice agents that handle real calls in production.
See how AI voice agents work for your industry. Live demo available -- no signup required.
The 2026 desktop AI agent landscape — ServiceNow Project Arc, Anthropic Claude offerings, OpenAI agents, and Google Mariner. A buyer's map.
Step-by-step build of a working agent with the OpenAI Agents SDK — Agent class, tools, handoffs, tracing — plus an eval pipeline that catches regressions before merge.
An agentic-AI perspective on Anthropic Skills system, covering orchestration patterns, tool use, and how agent tooling fits production agent stacks.
Enterprise CIO Guide perspective on Comet's general-availability launch put an agentic browser in front of millions of consumers, and it works better than the demos suggested.
Enterprise CIO Guide perspective on Harvey AI's enterprise rollout numbers show legal agents have moved past the pilot stage at AmLaw 100 firms.
Enterprise CIO Guide perspective on Hippocratic AI's deployment numbers show healthcare voice agents are moving from pilot to production across major US health systems.
© 2026 CallSphere Inc. All rights reserved.
Made within San Francisco
Watch how CallSphere handles real customer calls, schedules appointments, and processes payments — live.