Skip to content
Learn Agentic AI11 min read0 views

Building an Approval Workflow Agent: Human-in-the-Loop for Sensitive Actions

Build an AI agent with human approval gates for sensitive actions, including notification systems, configurable timeout handling, delegation chains, and audit logging for compliance.

Why Agents Need Approval Gates

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.

Designing the Approval System

The core abstraction is an approval request that captures the proposed action, who requested it, who can approve it, and the current status:

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

The approval manager handles the lifecycle of requests — creating them, waiting for decisions, and enforcing timeouts:

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.

See AI Voice Agents Handle Real Calls

Book a free demo or calculate how much you can save with AI voice automation.

Integrating with an Agent

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}"}

Delegation Chains

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(),
    })

Building the Notification Layer

Notifications can use any channel — email, Slack, or a web dashboard. Here is a simple multi-channel notifier:

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})

FAQ

How do I prevent the agent from bypassing the approval gate?

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.

What timeout value should I use for approval requests?

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.

How do I handle approvals when the agent runs outside business hours?

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

Share this article
C

CallSphere Team

Expert insights on AI voice agents and customer communication automation.

Try CallSphere AI Voice Agents

See how AI voice agents work for your industry. Live demo available -- no signup required.