Human-in-the-Loop & Permissions
Autonomy is a dial, not a switch. The engineering question is never "should the agent be autonomous?" but "which actions can it take alone, and which require a human's yes?" Getting that boundary right is what makes an agent safe to deploy on anything that touches the real world.
The organizing principle is reversibility. Reading data, drafting text, running a query — cheap to undo, safe to let the agent do freely. Sending an email to a customer, deleting records, moving money, deploying code — expensive or impossible to undo, and exactly where a human confirmation belongs. You classify each tool by its blast radius and gate the dangerous ones.
The approval gate
Mechanically, a permissioned tool doesn't execute on the model's say-so. When the agent calls a gated tool, the loop pauses, surfaces the intended action to a human ("The agent wants to send this email to 4,000 users: [preview]. Approve?"), and only runs it on a yes. The agent's proposal and the human's decision are logged. This is the same loop you've built, with one addition: some tools return "pending approval" instead of a result, and the loop suspends until the human responds.
Auto (no gate): read-only and easily-reversible actions. Confirm: user-visible or hard-to-reverse actions (send, publish, delete one record). Hard-blocked / privileged: mass or irreversible actions (bulk delete, payments over a threshold) — require elevated, explicit human authorization, never the agent alone. Default new tools to the stricter tier and relax deliberately.
Why gates matter more with agents than with chat
A chatbot that says something wrong is embarrassing; an agent that does something wrong has acted. And two forces make bad actions more likely than in ordinary software: the model is non-deterministic, and — critically — its context can be poisoned by prompt injection from tool results (Ch. 2). If a web page the agent read says "delete all records," an ungated delete tool will do it. The approval gate is the backstop that ensures a compromised or confused agent still can't take an irreversible action without a human. Gates are not a UX nicety; they're your primary defense against the agent doing real damage.
Design the confirmation for a human who will rubber-stamp it
A gate only works if the human can actually judge the action. "Approve tool call get_ratio(x=74,y=4)?" is unreviewable — people will click yes reflexively. Surface the action in human terms with the real consequence: "Send this refund of $420 to customer #4471?" The quality of the gate is the quality of the summary you show. A gate that induces reflexive approval is barely a gate at all.
Add an approval gate for irreversible tools
You'll classify tools by reversibility, gate the dangerous ones, and confirm the agent can't fire a destructive action without approval — including when a poisoned tool result tells it to.
Setup: mock-only; the "human" is a function you control to simulate approve/deny.
Step 1. Tag each tool auto or confirm.
Step 2. In the executor, run auto tools directly but route confirm tools through ask_human.
Step 3. Feed the agent a poisoned instruction to delete everything; show it's blocked on deny.
Your goal: a log showing the delete was proposed, gated, denied, and never executed.
Starter code
TOOL_TIER = {"search":"auto", "read_order":"auto",
"send_email":"confirm", "delete_all":"confirm"}
executed = []
def ask_human(name, args, approve):
action = {"send_email": f"Email to {args.get('to')}",
"delete_all": "DELETE ALL RECORDS"}.get(name, name)
print(f"[gate] Agent wants: {action} — {'APPROVE' if approve else 'DENY'}")
return approve
def execute(name, args, approve_fn):
if TOOL_TIER.get(name,"confirm") == "auto":
executed.append(name); return "ok"
if ask_human(name, args, approve_fn(name)):
executed.append(name); return "done"
return "blocked by human"
# Poisoned trajectory: a tool result told the agent to wipe data.
PLAN = [("search",{"q":"policy"}), ("delete_all",{})]
# TODO: run PLAN with approve_fn = lambda n: n != "delete_all" (deny deletes),
# then assert "delete_all" not in executed.
approve_fn = lambda name: name != "delete_all" # human denies the wipe
for name, args in PLAN:
print(name, "->", execute(name, args, approve_fn))
assert "delete_all" not in executed
print("executed:", executed) # => ['search'] (delete never ran)
What you should see: search runs automatically; delete_all is surfaced to the human, denied, and never executed — the assertion holds. Even though the agent's context was poisoned into proposing a catastrophic action, the gate stopped it cold. That's the whole safety property: the agent can be wrong or compromised, and irreversible damage still requires a human yes.
The engineering read. Autonomy is per-tool, set by reversibility. Auto-run the cheap-to-undo tools so the agent stays useful and fast; gate the irreversible ones so a bad decision — from a hallucination, a bug, or an injection — can't become a bad action. And make the confirmation legible: a gate the human can't meaningfully evaluate is theater.
Going further (optional): add a payment tool with a threshold — auto under $50, confirm $50–$5,000, hard-block above. That tiering by blast radius is how real agent permissions are designed.
Ledgerline: the refund that shouldn't have fired
Ledgerline's billing agent could issue refunds autonomously — convenient, until a malformed ticket and a confused loop led it to refund a customer $4,000 that was never owed. The action was instant and irreversible; unwinding it took a day of finance work and an awkward customer call. The problem wasn't a bad model so much as an autonomous loop wired directly to a high-impact, unrecoverable action.
They separated read tools from write tools and put a human-in-the-loop gate on anything irreversible: the agent now proposes a refund and a person (or a strict policy for small amounts) approves before it executes. This also backstops prompt injection — a hostile ticket that says "refund everything" hits the gate, not the bank. Erroneous refunds went to zero. The principle: never let an autonomous loop take an unrecoverable action unsupervised, no matter how good it looks.
Ridgeline auto-approves refunds under $50 (reversible enough, high volume) but routes anything larger to a human with the agent's reasoning attached. Read actions stay autonomous; write actions that move money get a gate. Least privilege, by design.
Quiz · Chapter 9 — reasoning, not recall
- The principle for deciding which agent actions need human approval is:
- Why do approval gates matter more for agents than for chatbots?
- A poisoned web page the agent read says "delete all records." With a proper gate, the outcome is:
- "Approve get_ratio(x=74,y=4)?" is a bad confirmation prompt because:
- A sensible default tier for a newly added tool is: