6 min read

Tool Approval Is Product Design, Not a Checkbox

Mehdi Rezaei
Mehdi
Author
Engineering
Software
Technology

I keep seeing the same mistake in agent demos: a toggle labeled "require approval for tools," a green check, and a claim that the product is now safe. That toggle is not a product. It is a reminder that someone has not designed the wait state yet.

Tool approval is a workflow. It has a pause, a human decision, a resume path, and a set of side effects that may already have started before anyone clicked anything. If you treat it like a permission flag on a chat completion, you will ship an agent that either blocks on every `ls` or quietly mutates production while the approver is in a meeting.

Pause is a state machine, not a spinner

When an agent hits a gated tool, the run has to stop in a way that can survive tab close, deploy, and the human ignoring Slack for four hours. That means a durable status — something like `waiting_for_approval` — owned by the runtime, not by the browser WebSocket.

I have watched teams store "pending approval" only in React state. The user refreshes. The run thinks it was denied. Or worse: the run thinks nothing happened and fires the tool again. Pause without persistence is just a hung UI with ambition.

Resume has the same problem in reverse. Approving should not replay the whole plan from step one. It should continue from the blocked tool call with a recorded decision. Durable agent runtimes finally make this boring and first-class: the run id survives, the pending tool call has an id, and the next worker pick-up can see "approved" or "denied" without guessing from chat text.

The runtime gives you the hooks. Product still owns the policy.

Who approves is a product question

"Human in the loop" collapses when you do not name the human.

Is it the developer who started the run? The repo owner? An on-call engineer for anything that touches deploy or secrets? A team channel where anyone with write access can rubber-stamp?

I have seen internal agents where the person who typed the prompt was also the only person who could approve a database migration. That works for a solo sandbox. It fails the first time an intern starts a run that opens a PR against `main` and then goes to lunch. It also fails when the starter is a bot account from a webhook.

Decide the approver by risk, not by session ownership:

  • read-only repo inspection: no approval, or optional
  • write to a branch / open a draft PR: starter or repo write access
  • merge, deploy, rotate secrets, charge a customer: a role that is not the agent and preferably not the same person who prompted it casually

If your product cannot express "this needs an owner from the platform team," you do not have tool approval. You have a modal.

The approver needs evidence, not vibes

A button that says "Allow `execute_sql`" with no SQL is useless. A button that dumps the entire agent transcript is also useless — nobody reads forty turns before a standup.

What I want on an approval card:

  • the tool name and a short intent ("run migration against staging Postgres")
  • the exact arguments, redacted where needed
  • why the agent thinks this is next (one or two sentences from the run, not the full chain of thought dump)
  • what already happened (files touched, previous tool results, whether a side effect might already be live)
  • blast radius: environment, repo, queue, customer scope
  • expiry: how long this approval stays valid before the run dies or re-asks

Without that context, people approve from fatigue. Fatigue approvals are how you get an incident with a clean audit log of someone clicking Yes.

Approve and deny both need idempotency

This is the part that burns you in production.

The human approves. The webhook retries. Your handler runs the tool twice. Or the human denies, the UI times out, they deny again, and the second deny races a stale resume that already started the tool.

Treat the approval decision like a payment webhook:

ts
1type ToolApprovalDecision = {
2 runId: string
3 toolCallId: string
4 decision: "approved" | "denied"
5 decidedBy: string
6 decidedAt: string
7 idempotencyKey: string
8}

Store the decision once keyed by `toolCallId` (or a dedicated idempotency key). Executing the tool is a separate step that checks "decision exists and is approved and not yet executed." Denied means terminal for that tool call — do not leave the run in a half-open state where a later retry can sneak through.

Also decide what deny means for the whole run. Abort? Ask the agent to choose another path? Mark the run failed with a human reason? Different products need different answers, but "deny" that silently continues is a bug with a polite label.

Audit trails are for the Tuesday after the outage

You will not care about the approval UX when everything works. You will care when a deploy went out at 11pm and three people swear they never approved it.

Log, at minimum:

  • who requested the run
  • which tool call was gated
  • arguments hash or redacted payload
  • who approved or denied, from which surface
  • timestamps for request, decision, execution
  • whether execution was skipped because of a duplicate decision

Keep this outside the chat transcript. Transcripts get trimmed, summarized, and rewritten by the model. An audit row should be dull, append-only, and boring enough that legal and security can read it without learning your prompt style.

Durable runtimes do not finish the job

Agents that can pause on a tool call, wake on a webhook, and resume without losing the sandbox are a real improvement over "hope the chat tab stayed open." I want that infrastructure. I use that infrastructure.

But durability without product ownership just makes bad policy last longer. If anyone can approve a production deploy from a phone notification with no args, a durable runtime will faithfully record that mistake every time.

So own it as product design: define the wait state, name the approver, ship the evidence card, make approve/deny idempotent, keep an audit trail that outlives the model session. The checkbox can stay in the admin panel if you want. Just do not pretend it is the feature.

Share this article