Human-in-the-Loop AI Agents: How to Build Approval Workflows That Actually Work
The Autonomy Spectrum: Not Every Action Deserves Auto-Approve
The AI agent discourse in 2026 has split into two camps: the "let agents do everything" crowd and the "humans must approve everything" crowd. Both are wrong.
Fully autonomous agents are how you get the Meta AI incident — an agent with broad tool access making decisions no human reviewed. But requiring approval for every action defeats the purpose of having an agent in the first place. You've just built a very expensive CLI with extra steps.
The answer is a tiered approval model: auto-approve low-risk actions, require human sign-off for high-risk ones, and deny everything else by default.
The Three-Tier Pattern
Every production AI agent should classify its available tools into three buckets:
| Tier | Policy | Examples | Latency Impact |
|---|---|---|---|
| Green | Auto-allow | Read database, search, list files, fetch URLs | None — agent proceeds immediately |
| Yellow | Require approval | Send email, write to DB, update config, publish content | Pauses until human approves |
| Red | Always deny | Delete data, drop tables, modify permissions, transfer funds | None — blocked instantly |
This isn't a new idea — it's how every enterprise permission system works. RBAC, ABAC, IAM policies. The only new thing is applying it to AI agents, which have been operating in a lawless wild west of "the system prompt says don't do that."
Implementing Approval Gates with TokenFence
TokenFence's AgentGuard Policy engine has first-class support for the requireApproval pattern. Here's a real-world example — an AI agent that manages a company's blog:
from tokenfence import Policy
content_agent_policy = Policy(
name="content-manager",
allow=[
"blog_list_*", # Read any blog data
"blog_read_*", # Read individual posts
"analytics_read_*", # Check traffic stats
"seo_audit_*", # Run SEO checks
],
require_approval=[
"blog_publish_*", # Publishing needs human review
"blog_update_*", # Edits to live posts need review
"email_send_*", # Outbound emails always reviewed
"social_post_*", # Social media posts reviewed
],
deny=[
"blog_delete_*", # Never delete published content
"user_modify_*", # Never touch user accounts
"billing_*", # Never touch billing
"db_raw_*", # No raw database access
],
default="deny", # Anything not listed = blocked
)
When the agent tries to use a tool, the policy returns one of three results:
# Green tier — auto-allowed
result = content_agent_policy.check("blog_list_posts")
assert result.allowed # True — agent proceeds
# Yellow tier — needs approval
result = content_agent_policy.check("blog_publish_draft")
assert result.requires_approval # True — pause and ask human
# Red tier — denied
result = content_agent_policy.check("blog_delete_post_42")
assert result.denied # True — blocked, logged, agent told "no"
Building the Approval Queue
The requireApproval result is the interesting one. When a tool call hits this tier, your agent framework needs to:
- Pause the agent — save its current state and tool call request
- Notify a human — Slack message, email, dashboard alert
- Wait for a decision — approve, deny, or modify the request
- Resume or abort — feed the decision back to the agent
Here's a minimal implementation using TokenFence's approval callback:
import asyncio
from tokenfence import Policy
# Simulate an approval queue (in production, this is your Slack bot / dashboard)
pending_approvals = asyncio.Queue()
async def request_human_approval(tool_name: str, context: dict) -> bool:
"""Send to approval queue and wait for response."""
future = asyncio.get_event_loop().create_future()
await pending_approvals.put({
"tool": tool_name,
"context": context,
"future": future,
})
# In production: send Slack notification here
print(f"⏳ Awaiting approval for: {tool_name}")
return await future # blocks until human responds
policy = Policy(
name="blog-agent",
allow=["read_*", "search_*"],
require_approval=["publish_*", "update_*"],
deny=["delete_*"],
on_approval=request_human_approval,
)
# When agent wants to publish:
result = await policy.async_check("publish_blog_post", context={
"title": "New Product Launch",
"scheduled_for": "2026-03-23T09:00:00Z",
})
if result.requires_approval:
approved = await result.request_approval()
if approved:
# Execute the tool call
pass
else:
# Tell agent the action was denied by human
pass
The TypeScript Version
Same pattern, now in TypeScript with TokenFence's Node.js SDK:
import { Policy, type PolicyResult } from "tokenfence";
const policy = new Policy({
name: "content-agent",
allow: ["read_*", "search_*", "analytics_*"],
requireApproval: ["publish_*", "update_*", "email_*"],
deny: ["delete_*", "admin_*"],
default: "deny",
onApproval: async (toolName: string, context: Record<string, unknown>) => {
// Send to your approval system (Slack, dashboard, etc.)
const response = await sendSlackApproval({
channel: "#agent-approvals",
message: `Agent wants to call \`${toolName}\` — approve?`,
context,
});
return response.approved;
},
});
// In your agent loop:
async function executeToolCall(toolName: string, args: unknown) {
const result = policy.check(toolName);
if (result.denied) {
return { error: `Action \`${toolName}\` is not permitted by policy.` };
}
if (result.requiresApproval) {
const approved = await policy.requestApproval(toolName, { args });
if (!approved) {
return { error: "Action denied by human reviewer." };
}
}
// Proceed with actual tool execution
return await callTool(toolName, args);
}
Real-World Approval Patterns
Here are five battle-tested patterns from teams running AI agents in production:
1. Time-Bounded Auto-Approve
During business hours (9am-5pm), certain actions auto-approve. Outside hours, they require manual review. This works well for content teams where an editor is available during the day.
def time_based_approval(tool_name, context):
from datetime import datetime
hour = datetime.now().hour
if 9 <= hour < 17: # Business hours
return True # Auto-approve
return None # Fall through to human review
2. Threshold-Based Escalation
Low-impact actions auto-approve; high-impact escalate. An email to 1 person? Auto-approve. A blast to 10,000? Human review.
def threshold_approval(tool_name, context):
if tool_name == "email_send_bulk":
recipient_count = context.get("recipient_count", 0)
if recipient_count > 50:
return None # Requires human
return True # Small batch, auto-approve
return None
3. First-N Auto-Approve
The first 5 actions of a type auto-approve. After that, every action requires review. Prevents runaway loops while allowing normal operation.
4. Dry-Run Preview
Before executing, the agent shows what it would do. Human reviews the preview and approves or rejects. This is particularly effective for database writes and content publishing.
5. Approval with Modification
Human can modify the tool call before approving. Agent wants to send email to 500 people? Human adjusts to 50 for a test batch and approves.
The Audit Trail: Why It Matters
Every policy check — allowed, denied, or approval-gated — gets logged in TokenFence's audit trail:
policy = Policy(name="blog-agent", allow=["read_*"], deny=["delete_*"])
policy.check("read_posts")
policy.check("delete_everything")
policy.check("unknown_tool")
# Every decision is recorded
for entry in policy.audit_trail:
print(f"{entry.timestamp} | {entry.tool} | {entry.decision} | {entry.reason}")
# Output:
# 2026-03-22T06:00:00Z | read_posts | allow | matched: read_*
# 2026-03-22T06:00:01Z | delete_everything| deny | matched: delete_*
# 2026-03-22T06:00:02Z | unknown_tool | deny | default policy: deny
This audit trail is non-optional for any regulated industry. Healthcare, finance, legal — if an AI agent takes an action, there must be a record of why it was allowed and who approved it (human or policy).
Common Mistakes in Approval Workflows
Mistake 1: Approving Everything
If your approval queue gets 200 requests per hour, humans start rubber-stamping. The solution: auto-approve the boring stuff so humans only see the 5 actions that actually matter.
Mistake 2: No Timeout on Approvals
Agent requests approval on Friday at 5pm. Nobody reviews until Monday. The action is now stale and potentially harmful. Always set TTLs on approval requests.
Mistake 3: No Fallback for Denied Actions
When a human denies an action, the agent needs a graceful fallback — not an infinite retry loop. Design your agent to accept "no" and either try an alternative approach or report back that it couldn't complete the task.
Mistake 4: Prompt-Based "Policies"
"You must always ask before sending emails" in a system prompt is not a policy. It's a wish. Models hallucinate, ignore instructions, and get prompt-injected. Runtime enforcement is the only reliable approach.
Getting Started
Install TokenFence and define your first policy in under 60 seconds:
# Python
pip install tokenfence
# Node.js / TypeScript
npm install tokenfence
from tokenfence import Policy
policy = Policy(
allow=["read_*"],
require_approval=["write_*"],
deny=["delete_*"],
)
# Wrap your agent's tool execution
result = policy.check("write_user_profile")
if result.requires_approval:
# Route to your approval system
pass
Two lines to define what your agent can do. One function call to enforce it. No prompt engineering required.
Read the full documentation, check out the example repo, or explore our blog for more patterns.
TokenFence is the safety layer for AI agents. Budget caps, policy enforcement, and audit trails — because trust is earned through enforcement, not prompts.
Ready to protect your AI budget?
Two lines of code. Per-workflow budgets. Automatic model downgrade. Hard kill switch.