Skip to content
Blog

Approval Gates: Pausing Agent Workflows for Human Sign-Off

Implement approval gates in agent workflows. Pause execution before critical actions, route to human reviewers, and resume with approval or rejection.

Published on September 8, 2026

AI Assistant

Autonomous agents are powerful, but some actions need a human in the loop. Sending an email, deleting a database record, making a payment — these aren’t decisions you want an agent making alone. Approval gates give you controlled autonomy: the agent does the thinking, but humans approve the doing.

Why Approval Gates

The core tension in agent design is between autonomy and safety. Too much autonomy and agents make costly mistakes. Too little and they’re just expensive form-filling tools.

Approval gates find the middle ground:

  • Agent proposes — The agent plans the action, gathers context, and presents a recommendation
  • Human disposes — A human reviews, approves, rejects, or modifies the action
  • Agent executes — Only approved actions are carried out

This pattern is essential for:

  • Financial transactions (payments, transfers, refunds)
  • External communications (emails, messages, social posts)
  • Data mutations (deletes, overwrites, schema changes)
  • Compliance-sensitive actions (legal, medical, regulatory)

Implementation Patterns

LangGraph with Interrupt

LangGraph’s interrupt function pauses execution and waits for human input:

from langgraph.graph import StateGraph, MessagesState, START, END
from langgraph.types import interrupt
from langgraph.checkpoint.memory import MemorySaver

def draft_email(state: MessagesState):
    """Agent drafts an email for review."""
    last_message = state["messages"][-1].content
    
    draft = llm.invoke(f"""Draft a professional email based on this request:
    
{last_message}

Include subject line, greeting, body, and signature.""")
    
    return {
        "messages": [draft],
        "pending_action": {
            "type": "send_email",
            "content": draft.content,
            "recipient": extract_recipient(last_message),
        }
    }

def approval_gate(state: MessagesState):
    """Pause and wait for human approval."""
    pending = state.get("pending_action", {})
    
    # This pauses execution and shows the pending action to the human
    human_decision = interrupt({
        "action_type": pending.get("type"),
        "content": pending.get("content"),
        "instructions": "Approve, reject, or modify this action"
    })
    
    return {"approval_decision": human_decision}

def execute_action(state: MessagesState):
    """Execute only if approved."""
    decision = state.get("approval_decision", {})
    
    if decision.get("approved"):
        # Execute the action
        if state["pending_action"]["type"] == "send_email":
            send_email(
                to=state["pending_action"]["recipient"],
                subject=decision.get("subject", "No Subject"),
                body=decision.get("content", state["pending_action"]["content"])
            )
        return {"messages": [("ai", "Action executed successfully")]}
    else:
        return {"messages": [("ai", f"Action rejected: {decision.get('reason', 'No reason provided')}")]}

# Build the graph
graph = StateGraph(MessagesState)
graph.add_node("draft", draft_email)
graph.add_node("approve", approval_gate)
graph.add_node("execute", execute_action)

graph.add_edge(START, "draft")
graph.add_edge("draft", "approve")
graph.add_conditional_edges(
    "approve",
    lambda s: "execute" if s.get("approval_decision", {}).get("approved") else END
)
graph.add_edge("execute", END)

checkpointer = MemorySaver()
app = graph.compile(checkpointer=checkpointer)

Reacting to Approval

The human provides their decision via update_state:

config = {"configurable": {"thread_id": "email-draft-1"}}

# Agent runs until approval gate
result = app.invoke({"messages": [("user", "Send a follow-up email to john@example.com")]}, config)

# Human reviews and provides decision
app.update_state(
    config,
    {
        "approval_decision": {
            "approved": True,
            "subject": "Following Up on Our Discussion",
            "modifications": ["Make the tone more casual"]
        }
    }
)

# Resume execution
result = app.invoke(None, config)

Conditional Approval Gates

Not every action needs the same level of scrutiny:

class ApprovalConfig:
    def __init__(self):
        self.gates = {
            "read_data": {"required": False},
            "write_data": {"required": True, "timeout_seconds": 300},
            "send_email": {"required": True, "timeout_seconds": 3600},
            "delete_record": {"required": True, "approver_role": "admin"},
            "payment": {"required": True, "approver_role": "finance", "timeout_seconds": 86400},
        }
    
    def needs_approval(self, action_type: str) -> bool:
        gate = self.gates.get(action_type, {"required": True})
        return gate["required"]
    
    def get_timeout(self, action_type: str) -> int:
        gate = self.gates.get(action_type, {"timeout_seconds": 300})
        return gate["timeout_seconds"]

UI for Approval Workflows

Web Dashboard

from fastapi import FastAPI, HTTPException
from pydantic import BaseModel

app = FastAPI()

class ApprovalDecision(BaseModel):
    approved: bool
    reason: str = ""
    modifications: dict = {}

@app.get("/pending-approvals/{session_id}")
async def get_pending(session_id: str):
    """Get pending approval requests for a session."""
    pending = await get_pending_approvals(session_id)
    return {"approvals": pending}

@app.post("/approve/{approval_id}")
async def approve_action(approval_id: str, decision: ApprovalDecision):
    """Submit an approval decision."""
    await submit_decision(approval_id, decision)
    return {"status": "decision_recorded"}

@app.get("/approval-status/{approval_id}")
async def check_status(approval_id: str):
    """Check if an approval has been made."""
    status = await get_approval_status(approval_id)
    return status

Slack Integration

import slack_sdk

def send_approval_to_slack(approval_request: dict) -> str:
    """Send approval request to Slack and wait for response."""
    client = slack_sdk.WebClient(token=os.environ["SLACK_BOT_TOKEN"])
    
    # Send message with buttons
    response = client.chat_postMessage(
        channel="#agent-approvals",
        text=f"Action requires approval: {approval_request['action_type']}",
        blocks=[
            {
                "type": "section",
                "text": {
                    "type": "mrkdwn",
                    "text": f"*Agent Action Request*\n\n"
                            f"*Type:* {approval_request['action_type']}\n"
                            f"*Details:* {approval_request['content'][:200]}"
                }
            },
            {
                "type": "actions",
                "elements": [
                    {
                        "type": "button",
                        "text": {"type": "plain_text", "text": "Approve"},
                        "action_id": f"approve_{approval_request['id']}",
                        "style": "primary"
                    },
                    {
                        "type": "button",
                        "text": {"type": "plain_text", "text": "Reject"},
                        "action_id": f"reject_{approval_request['id']}",
                        "style": "danger"
                    }
                ]
            }
        ]
    )
    
    return response["ts"]

Handling Timeouts

Agents shouldn’t block forever waiting for approval:

import asyncio
from datetime import datetime, timedelta

class ApprovalTimeoutHandler:
    def __init__(self, default_timeout: int = 300):
        self.default_timeout = default_timeout
    
    async def wait_for_approval(
        self,
        approval_id: str,
        timeout: int = None
    ) -> dict:
        timeout = timeout or self.default_timeout
        start = datetime.now()
        
        while (datetime.now() - start).seconds < timeout:
            status = await get_approval_status(approval_id)
            
            if status["decided"]:
                return status["decision"]
            
            await asyncio.sleep(5)  # Poll every 5 seconds
        
        # Timeout reached
        return {
            "approved": False,
            "reason": "Approval timeout",
            "timed_out": True
        }

Best Practices

Clear Context

Always provide enough context for informed decisions:

def build_approval_context(action: dict, agent_state: dict) -> dict:
    return {
        "action_type": action["type"],
        "action_details": action["content"],
        "reasoning": agent_state.get("reasoning", "No reasoning provided"),
        "risk_level": assess_risk(action),
        "similar_past_actions": get_similar_actions(action["type"]),
        "estimated_impact": estimate_impact(action),
    }

Audit Trail

Log every approval decision:

class ApprovalAuditLog:
    def log_decision(
        self,
        approval_id: str,
        action: dict,
        decision: dict,
        reviewer: str
    ):
        log_entry = {
            "timestamp": datetime.now().isoformat(),
            "approval_id": approval_id,
            "action_type": action["type"],
            "action_content_hash": hash(action["content"]),
            "approved": decision["approved"],
            "reviewer": reviewer,
            "reason": decision.get("reason", ""),
            "response_time_ms": calculate_response_time(approval_id),
        }
        
        self._store(log_entry)

Escalation

If primary approvers don’t respond, escalate:

class EscalationPolicy:
    def __init__(self):
        self.escalation_chain = [
            {"level": 1, "timeout": 300, "notify": ["team-lead"]},
            {"level": 2, "timeout": 600, "notify": ["engineering-manager"]},
            {"level": 3, "timeout": 1800, "notify": ["cto"]},
        ]
    
    def get_next_level(self, current_level: int) -> dict:
        if current_level < len(self.escalation_chain):
            return self.escalation_chain[current_level]
        return None

Conclusion

Approval gates are the bridge between autonomous agents and responsible systems. They let agents do the thinking while humans control the doing. Start with gates on high-risk actions, add clear context and audit trails, handle timeouts gracefully, and build escalation policies for when approvers are unavailable.