Last month I wrote about an agent that charged a customer twice in eleven seconds. The fix wasn't a better model or a cleverer prompt — it was a rule I resisted for weeks because it felt like admitting defeat:
Nothing that moves money or leaves the building happens without a human saying yes.
So I built an approval gate. Every action my agents take gets classified: read-only stuff runs free, anything that sends an email, posts publicly, calls a payment API, or touches production config gets parked in a queue until I approve it from my phone.
Day 1 felt like genius. Day 3 I had 47 pending approvals, I was rubber-stamping them without reading, and I realized I had rebuilt the exact problem I was trying to solve — just with extra steps.
Here's what actually worked after I redesigned it, including the part where I nearly gave myself approval fatigue so bad it became worse than no gate at all.
The naive version (don't do this)
My first implementation was dead simple. Before any "risky" tool call, the agent writes a row to a SQLite table:
RISKY_TOOLS = {
"send_email", "post_to_social", "create_refund",
"charge_card", "deploy", "write_production_db",
}
def request_approval(agent_id: str, tool: str, payload: dict) -> str:
ticket_id = str(uuid.uuid4())[:8]
db.execute(
"INSERT INTO approvals (id, agent, tool, payload, status, created_at) "
"VALUES (?, ?, ?, ?, 'pending', datetime('now'))",
(ticket_id, agent_id, tool, json.dumps(payload)),
)
notify_me(ticket_id, agent_id, tool, payload) # push notification
return ticket_id
The agent loop then polls for a decision:
ticket = request_approval(agent_id, tool_name, args)
decision = wait_for_decision(ticket, timeout=3600) # 1 hour
if decision != "approved":
return {"status": "blocked", "reason": f"action required approval: {ticket}"}
And a 30-line Flask endpoint on my Pi lets me approve or deny from my phone. That's the whole system. It works. It is also, as configured above, a machine for generating 40+ notifications a day, because I had classified actions by tool instead of by consequence.
Why day 3 almost broke it
Three things went wrong, and all three were my fault:
-
I gated reads that looked like writes. My support agent's
send_emailtool was used for both customer replies and an internal daily digest. Every digest needed approval. Pointless. - Low-stakes actions drowned high-stakes ones. A $3 refund request and a "post this to X" request arrived in the same channel with the same urgency. After two days I stopped reading the payloads. I was approving on muscle memory — which is exactly the failure mode an approval gate exists to prevent. A gate you don't read is theater.
- No timeout policy meant silent stalls. When I forgot my phone, agents sat blocked for hours. Customers got replies at 11 PM. The gate had made the product worse, not safer.
The honest lesson: an approval system is a UX problem, not a security problem. If approving is annoying, you will degrade your own controls within a week. I've watched myself do it in real time.
The redesign: tier by consequence, not by tool
I now classify every action into three tiers, and only one of them involves me.
Tier 1 — Reversible and internal. Run free. Reading data, drafting content to a staging area, internal DB writes that have an undo path. No notification, no wait.
Tier 2 — Reversible but external. Auto-approve with a delay and an audit log. Example: routine support replies. The agent sends, but the message goes out through a queue with a 10-minute delay, and a second cheap LLM pass flags anything that mentions refunds, pricing, or legal language. Flagged items get promoted to Tier 3. In practice ~4% of messages get flagged, and the flagger has caught real problems twice — once when an agent promised a "lifetime" discount it had no authority to offer.
Tier 3 — Irreversible or money-moving. Hard block until human approval. Charges, refunds above $5, public posts, deploys, production schema changes, anything touching credentials. These get a push notification with a diff-style summary: what will happen, to whom, for how much, and why the agent thinks it should.
The tiering logic is just a function — no framework needed:
def tier_for(tool: str, payload: dict) -> int:
if tool in ("charge_card", "deploy", "write_production_db"):
return 3
if tool == "create_refund":
return 3 if payload.get("amount_cents", 0) > 500 else 2
if tool == "send_email":
return 2 # queue + flagger may promote to 3
if tool in ("post_to_social",):
return 3
return 1
The numbers matter less than the principle: tier by blast radius and reversibility, not by which function got called.
Three details that made it survivable
Batched approvals. Tier 3 requests now arrive in a twice-daily digest unless they're flagged urgent (a live customer waiting, a deploy window). My approval count went from ~47/day to ~6/day, and — this is the part that surprised me — I actually read them again. Friction per item went up; total friction went down.
Expiry with a safe default. Every pending approval expires after 4 hours and defaults to deny, and the agent is required to tell the customer "this is taking longer than usual, I'll follow up" rather than going silent. A stale approval queue is a lying queue.
The agent must argue its case. The approval payload includes the agent's reasoning and the specific evidence it used (order ID, email thread, exact amount). Writing that field forced me to make the agent's context inspectable — which paid off separately, because it's how I caught a hallucinated order number in week two. If the agent can't cite evidence for an irreversible action, that's a deny on its own.
What it costs, honestly
Latency. Tier 3 actions can wait hours, and if you're building something customer-facing you need the "I'll follow up" paths to be real. I've lost the odd sale to a slow approval. I'll take that over the alternative — the double-charge incident cost me more in trust (and one very reasonable but very public complaint) than every delayed approval combined.
Maintenance. The tier function is policy, and policy drifts. Once a month I read the audit log end-to-end and ask two questions: did anything Tier 1/2 do damage? Did anything Tier 3 get denied that shouldn't have been? Both answers change the tiers. It's maybe an hour a month and it's the highest-leverage hour in my whole agent stack.
If you build one thing from this post
Don't gate tools. Gate consequences. Write down every action your agent can take, score each one "can I undo this in under five minutes?" and "does this touch money, reputation, or credentials?" — and put a human in the loop only where both answers are bad. Then design the approval UX as if your future tired self is the user, because your future tired self is definitely the user, and a tired approver is an auto-approver.
The gate is not the point. Reading the gate is the point.
I write up the specific playbooks in The Solo Operator's AI Agent Playbook — code LAUNCH90 at checkout makes it $1.90. If it doesn't save you 5 hours in week one, reply to the receipt for a refund.
Top comments (0)