DEV Community

Cover image for AI Agents That Ask Before They Act: Approval Gates in TypeScript
Gabriel Anhaia
Gabriel Anhaia

Posted on

AI Agents That Ask Before They Act: Approval Gates in TypeScript


The mechanism for pausing an agent and waiting for a human is well documented:
interrupt the run, persist it, resume with the decision. That part is a solved
problem.

What is not solved, and what decides whether the gate is worth anything, is
everything around it. Which actions get gated. What the reviewer sees. What
happens when nobody looks. Get those wrong and you have built a queue that
people click through, which is worse than no gate, because now the bad action
carries a human's name.

Gate on consequence, not on tool name

The instinct is a list of dangerous tools. That is too coarse in both
directions: refunding two euros does not need a human, and update_config
looks harmless until it changes a rate limit in production.

Score the action, arguments included:

export type Risk = "auto" | "notify" | "approve" | "block";

export function riskOf(call: ToolCall, ctx: Ctx): Risk {
  switch (call.name) {
    case "refund_order": {
      const cents = call.input.amountCents as number;
      if (cents <= 2_000) return "auto";
      if (cents <= 20_000) return "notify";
      return "approve";
    }
    case "send_email":
      return isInternal(call.input.to as string) ? "auto" : "approve";
    case "delete_account":
      return "block";               // never automated, ticket only
    default:
      return REVERSIBLE.has(call.name) ? "auto" : "approve";
  }
}
Enter fullscreen mode Exit fullscreen mode

Four levels rather than two. notify is the one people leave out and the one
that carries most of the value: the action proceeds, a human is told, and they
can intervene after the fact. For anything reversible, that is a better trade
than blocking — it keeps the agent useful and still puts a person in the loop.

The default matters too. A newly added tool is approve unless someone
deliberately marked it reversible, so forgetting to classify fails safe.

Give the reviewer enough to judge

An approval card that says "Refund order ord_8812 for €340?" has no answer.
Approve based on what?

export type ApprovalRequest = {
  runId: string;
  action: { tool: string; summary: string; args: Record<string, unknown> };

  // why the agent wants this
  userRequest: string;
  agentReasoning: string;
  toolsAlreadyRun: { name: string; outcome: string }[];

  // where the reasoning came from — the field that catches injection
  sourcesUsed: { id: string; title: string; excerpt: string }[];

  // what the reviewer needs to sanity-check it
  subject: { orderTotal: number; customerSince: string; priorRefunds: number };

  requestedBy: { userId: string; name: string };
  expiresAt: string;
};
Enter fullscreen mode Exit fullscreen mode

sourcesUsed is the one that earns its place. A reviewer who can see that the
refund was proposed right after reading a support ticket written by the
person receiving the refund
has what they need to decline. Without it they
are approving a plausible sentence.

subject is the second: a €340 refund on a €90 order is obviously wrong, and
obvious only if the order total is on the card.

Present the diff, not the intent

For anything that changes state, show before and after:

<Diff>
  <Row label="Order status" from="paid" to="refunded" />
  <Row label="Customer balance" from="€0.00" to="€340.00" />
  <Row label="Refunds this month" from="2" to="3" warn={next => next >= 3} />
</Diff>
Enter fullscreen mode Exit fullscreen mode

Reviewers are far better at spotting a wrong change than at evaluating a
proposed action described in prose. This is the same reason code review works
on diffs.

An approval card carrying the request, the sources used, and a before/after<br>
diff.

The gate in the loop

for (const call of toolCalls) {
  const risk = riskOf(call, ctx);

  if (risk === "block") {
    results.push(errorResult(call.id,
      "This action cannot be performed by an agent. Ask the user to open a ticket."));
    continue;
  }

  if (risk === "approve") {
    const decision = interrupt(await buildApproval(call, ctx));
    if (decision.verdict !== "approve") {
      results.push(errorResult(call.id,
        `A human declined this action: ${decision.reason ?? "no reason given"}. ` +
        `Do not retry it. Continue with the rest of the task.`));
      continue;
    }
    ctx.audit.approved(call, decision);
  }

  if (risk === "notify") void notify(call, ctx);   // fire and continue

  results.push(await execute(call, ctx));
}
Enter fullscreen mode Exit fullscreen mode

Two details. The rejection message tells the model not to retry and to carry
on — without that it either loops on the same call or abandons the whole task.

And ctx.audit.approved(...) records who approved what before the action
runs, not after. If the action then fails, you still know a human authorised
it.

Expiry is not optional

A queue with no expiry becomes a queue nobody trusts. The pending item from
three weeks ago is not pending, it is abandoned.

export async function expireStale() {
  const stale = await db.approval.findMany({
    where: { status: "pending", expiresAt: { lt: new Date() } },
    take: 200,
  });

  for (const a of stale) {
    await resumeRun(a.runId, { verdict: "reject", reason: "expired" });
    await db.approval.update({
      where: { id: a.id },
      data: { status: "expired" },
    });
    metrics.increment("approval.expired", 1, { tool: a.tool });
  }
}
Enter fullscreen mode Exit fullscreen mode

Reject on expiry rather than approve. For anything involving money or customer
contact, the safe default is not doing it, and the agent gets told, so it can
report back rather than hanging forever.

Set the window by risk: hours for a refund, a day or two for a config change.

Watch the approval rate, because it tells you the gate is broken

Two numbers, and both are worrying at the extremes.

metrics.increment(`approval.${decision.verdict}`, 1, { tool });
metrics.histogram("approval.latency_ms", Date.now() - createdAt, { tool });
Enter fullscreen mode Exit fullscreen mode

An approval rate near 100% means the gate is theatre. Either the risk
threshold is too low and you are gating things that are fine — in which case
move them to notify, or reviewers are rubber-stamping, which the latency
histogram will confirm. A p50 of four seconds on a card containing five
sources is not review.

An approval rate near 0% means the agent is consistently proposing wrong
actions, and the fix is upstream — tool design, prompt, or capabilities, not a
better queue.

The healthy shape is a high-but-not-perfect approval rate with a latency that
suggests someone read the card.

Approval rate and review latency together, distinguishing a working gate<br>
from<br>
rubber-stamping.

Batch, but never batch-approve

Reviewers will ask for a "select all" button. Resist it for anything
irreversible — one click approving twelve refunds is the failure mode this
entire system exists to prevent.

What is reasonable: group by type so a reviewer builds context once, and let
them approve individually within the group. Same efficiency, no single click
that authorises everything.

The uncomfortable part

Every gate is a bet that a human reviewing at speed catches what the model got
wrong. That bet gets weaker as volume rises.

So gate narrowly and gate the things that genuinely cannot be undone. A small
queue that people read beats a large one they clear, and the way to keep it
small is auto for reversible actions and notify for the middle, not a
broader approve.


If this was useful

AI That Plans covers human-in-the-loop
properly, where the interrupt goes, what durable state it needs, what the
reviewer must see, and the expiry and metrics that keep the gate honest.

AI That Plans — Stateful AI Agents with LangGraph.js

The full series is at
xgabriel.com/ai-in-typescript.

Top comments (0)