Split every agent tool into three kinds: read, draft and execute. Let the agent read and draft freely, but make execute tools write a pending action to a queue instead of running. A person reviews, edits or rejects each item. Only approved items run, exactly once, and every decision is logged with who approved it and when.
Which agent actions need human approval?
Sort actions by two questions: can it be undone, and will another person see it?
- Search threads, read analytics: risk: None; gate: Run freely
- Draft a reply or email (private): risk: Low; gate: Run freely
- Post a public reply: risk: Public, hard to undo; gate: Human approval
- Send an outreach email: risk: Reaches a person; gate: Human approval
- Schedule a social post: risk: Public; gate: Human approval
- Delete records, spend budget: risk: Irreversible; gate: Human approval
Reads and private drafts are cheap to get wrong. Anything public, sent to someone, paid for or deleted should wait for a person.
How do you design tools so the agent can't skip approval?
Don't give the agent the raw send. Give it a propose_* tool that only creates a pending item. If the send function isn't in the agent's toolset, no prompt can talk it into calling it.
[
{
"name": "draft_reply",
"description": "Write a reply draft for a thread. Does not post.",
"input_schema": {
"type": "object",
"properties": {
"thread_id": { "type": "string" },
"text": { "type": "string" }
},
"required": ["thread_id", "text"]
}
},
{
"name": "propose_send_email",
"description": "Queue an email for human review. Does not send.",
"input_schema": {
"type": "object",
"properties": {
"to": { "type": "string" },
"subject": { "type": "string" },
"body": { "type": "string" },
"reason": { "type": "string" }
},
"required": ["to", "subject", "body", "reason"]
}
}
]
The reason field matters. It makes the agent explain why it wants the action, which is the first thing a reviewer needs.
What does the pending-action queue look like?
A table with a status column is enough. Any database works.
from dataclasses import dataclass, field
from datetime import datetime, timezone
import hashlib, json, uuid
@dataclass
class PendingAction:
type: str # "send_email", "post_reply", ...
payload: dict
created_by: str # agent run id
idempotency_key: str
id: str = field(default_factory=lambda: str(uuid.uuid4()))
status: str = "pending" # pending, approved, rejected, done, failed, expired
reviewer: str | None = None
decided_at: datetime | None = None
created_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc))
def propose(action_type, payload, run_id):
digest = hashlib.sha256(json.dumps(payload, sort_keys=True).encode()).hexdigest()
key = f"{action_type}:{run_id}:{digest}"
if queue.exists(idempotency_key=key):
return queue.get(idempotency_key=key)
item = PendingAction(action_type, payload, run_id, key)
queue.save(item)
return item
How does the review step work?
The reviewer approves, edits or rejects. A separate worker runs approved items, and that worker is the only code allowed to call the real side effect.
def approve(action_id, reviewer, edits=None):
item = queue.get(action_id)
if item.status != "pending":
raise ValueError("Already decided")
if edits:
item.payload = {**item.payload, **edits}
item.status, item.reviewer = "approved", reviewer
item.decided_at = datetime.now(timezone.utc)
queue.save(item)
def reject(action_id, reviewer, reason):
item = queue.get(action_id)
item.status, item.reviewer = "rejected", reviewer
item.payload["reject_reason"] = reason
queue.save(item)
def execute_approved():
for item in queue.find(status="approved"):
try:
HANDLERS[item.type](item.payload) # e.g. send_email(), post_reply() stubs
item.status = "done"
except Exception:
item.status = "failed"
queue.save(item)
Show the reviewer the exact final payload: the recipient, the full text and the channel, plus the agent's reason and any sources it used. A summary is not enough to approve against.
How do you stop duplicates, stale approvals and runaway agents?
Four small guards cover most failure modes:
- Idempotency keys so the same proposal can't be queued or run twice.
- Expiry so an approval from last week doesn't fire on stale context.
- Caps per run and per day so a looping agent fills the queue slowly, not instantly.
- A kill switch that stops the worker without a deploy.
MAX_PER_DAY = 50
EXPIRY_HOURS = 24
def can_execute(item):
if settings.get("agent_actions_paused"):
return False
age = datetime.now(timezone.utc) - item.created_at
if age.total_seconds() > EXPIRY_HOURS * 3600:
item.status = "expired"
queue.save(item)
return False
return queue.count(status="done", since_hours=24) < MAX_PER_DAY
Call can_execute() at the top of the loop in execute_approved().
What should you log?
Log enough to answer "who let this happen?" months later.
- Proposal: type, payload, agent reason
- Edits: diff between proposed and approved payload
- Decision: approved or rejected, reviewer, timestamp
- Result: done or failed, error text, timestamp
Keep the original draft next to the edited one. Over time, the diffs show you where the agent's drafts need better instructions.
Where does this fit with MCP?
If your agent connects to tools over MCP (Model Context Protocol), expose only the read, draft and propose tools to it. Keep execution behind your own approval service. Many agent clients also ask the user to confirm a tool call, which is useful as a second layer, but it can be switched off and it doesn't leave an audit trail, so enforce approval on the server too.
FAQ
Isn't the client's confirm button enough? It helps, but it can be turned off and doesn't give you a record. Enforce approval on the server side as well.
Which actions can run without approval? Reads and private drafts. Anything another person will see, or that is hard to undo, should wait for a human.
Does approval slow everything down? Drafts are still produced instantly. Reviewing one queue a few times a day keeps the delay small.
What if a reviewer edits the draft? Run the edited version and keep the original for comparison.
Let your agent prepare everything, and let a person press the button.
Top comments (1)
The propose_* split holds as long as every side effect is its own tool. It leaks once the agent also has a generic tool, like a shell or a computer-use / browser tool: then "send" is just a click on a button, and nothing in the tool name tells your server that this click is the send.
What worked for us there: gate at the action level, not the tool level. Before a click, read the target's role and label from the accessibility tree, and route anything that looks like send, pay, delete or post into the same pending queue, with a screenshot of the filled form as the payload. Then re-read the field values right before the approved click and expire the approval if they changed, because the screen can drift between proposal and execution.
Do you keep generic tools out of agents that need approval entirely, or queue those actions too?