Skip to content

Designing Approval Flows for AI Actions

6 min read · updated August 3, 2026

The instinct when an AI feature can act on the world is to confirm everything. That produces a dialog users click through without reading within a week, at which point you have the friction and none of the protection.

Blast radius, in four axes

Friction should be proportional to what a mistake costs, and that is not one number. Four independent axes, each of which can be evaluated before the action runs:

  • Reversibility. Can the previous state be restored exactly, partially, or not at all? A sent message is irreversible even where a delete exists, because it may have been read.
  • Externality. Does anyone outside the user see it? An action visible only to the actor is recoverable socially as well as technically; one that reaches a customer is not.
  • Scope. One record or ten thousand. The same operation changes tier entirely with cardinality, and bulk operations are where agents do their most spectacular damage.
  • Cost. Money moved, resources provisioned, quota consumed. Distinct from reversibility: a refunded payment is reversible and still had consequences.

Four tiers of friction

TierDescription
0 — Just do itReversible, private, single, free. A draft, a suggested tag, a proposed filter. No confirmation at all; an undo in reach. Confirming here is pure cost and it spends the attention you need at tier 3.
1 — Do it, with a visible undo windowReversible, possibly visible, low cost. Execute optimistically and hold a few seconds of 'Undo' in a toast. Deferred execution makes the undo total rather than compensating.
2 — Confirm, showing the effectHard to reverse, or externally visible, or costly. One explicit confirmation that renders the actual change — the diff, the recipient, the amount — not a restatement of the request.
3 — Confirm with re-authentication or typed intentIrreversible and high cost, or bulk. Type the record count, re-enter a password, wait out a deliberate delay. Reserved, or it becomes tier 2 by habituation.

Why uniform confirmation fails

The result is well established outside AI. In the security-warning literature, Egelman, Cranor and Hong’s 2008 CHI study You’ve Been Warned examined browser phishing warnings and found that warning design and frequency substantially determine whether a warning is heeded at all, with habituation to frequently shown warnings a central concern. Wogalter’s Communication–Human Information Processing model makes the same point structurally: attention is the first link in the chain, and a stimulus that appears constantly loses it.

The AI-specific version is worse, because agents act many times per task. A user approving eleven steps of an agent run is not making eleven decisions; they are pressing a button eleven times to get to the result. The eleventh dialog has no informational content by then, and it is the one that would have caught the mistake.

Hence the tiering. The purpose of tier 0 and tier 1 is not convenience. It is to keep tier 2 rare enough that it is still read.

Show the effect, not the request

The most common defect in AI confirmation dialogs: they show what the user asked for. “Send an email to the team about the delay?” is a paraphrase of the prompt, and confirming it confirms nothing — the entire risk is that the model’s interpretation diverged from the request, and a paraphrase of the request cannot surface that.

The confirmation has to render the resolved action: the actual recipients, the actual subject and body, the actual amount, the actual rows. For a modification, a diff. For a bulk operation, the count plus a sample plus the filter that produced the set — because the usual failure there is not a wrong operation, it is a wrong selection.

A useful check: could the user detect a wrong interpretation from what the dialog shows? If not, the dialog is a speed bump rather than a control.

Scoring it

type Action = {
  reversible: "exact" | "partial" | "none";
  audience: "self" | "team" | "external";
  count: number;
  costMinorUnits: number;   // money moved, in cents
};

function frictionTier(a: Action): 0 | 1 | 2 | 3 {
  let score = 0;

  score += { exact: 0, partial: 2, none: 4 }[a.reversible];
  score += { self: 0, team: 1, external: 3 }[a.audience];
  score += a.count > 100 ? 3 : a.count > 10 ? 2 : a.count > 1 ? 1 : 0;
  score += a.costMinorUnits > 10_000 ? 3
         : a.costMinorUnits > 0      ? 1 : 0;

  if (score >= 8) return 3;
  if (score >= 4) return 2;
  if (score >= 2) return 1;
  return 0;
}

// Declared per tool, once, next to the tool definition — so a new
// tool cannot reach production without someone stating its radius.
const TOOLS = {
  draft_reply:   { reversible: "exact", audience: "self",     count: 1 },
  send_email:    { reversible: "none",  audience: "external", count: 1 },
  archive_issue: { reversible: "exact", audience: "team",     count: 1 },
  bulk_close:    { reversible: "partial", audience: "team",   count: "dynamic" },
};

The weights are a starting point, not a finding — tune them for your domain. What matters is the structure: the tier is computed from declared properties of the action, so adding a tool forces someone to state its blast radius, and the friction cannot be set by whoever happened to build the screen. It also makes the policy reviewable in one place, which a scattering of confirm() calls never is. This pairs directly with where to put the human in an agent loop.

Approving an agent’s whole plan

Step-by-step approval of a ten-step agent run is the habituation problem in its purest form. Two structures work better.

  • Plan-level approval. The agent produces the full plan, the user approves it once with per-step toggles, and execution runs unattended — pausing only if a step’s resolved parameters score into a higher tier than the plan implied. One considered decision beats ten reflexive ones.
  • Dry run. Execute against a simulation, show what would change, then approve the whole thing. Expensive to build, and the only approach that gives the user a real diff for a multi-step plan rather than a description of one.

Both need a hard stop the user can hit at any time, and the stop must leave the system in a described state — “stopped after step 4 of 9; steps 1–4 are done and here is what they did”. An abort that leaves the user guessing what happened is worse than no abort, because now they have to reconstruct it.

Plan-level approval has one failure mode worth designing against explicitly. A plan is a set of intentions; the parameters are resolved later, during execution, from whatever the agent has learned by then. So a step approved as “notify the affected customers” can resolve to three recipients or three thousand, and the approval the user gave was for the sentence rather than for the set. The rule that makes plan approval safe is therefore a re-check at execution time: score the resolved action, and if it lands in a higher tier than the plan implied, stop and ask. Without that, plan approval is strictly worse than per-step approval rather than better.

It is also worth keeping the audit trail separate from the approval UI. What the user approved, what was actually executed, and what changed between the two are three different records, and only the third one answers the question anybody asks afterwards. Storing the resolved parameters at execution time — not the plan text — is what makes that answerable, and it costs a row.

Designing Approval Flows for AI Actions · Multigrid