- Book: AI That Plans
- The series: AI in TypeScript — 5 books, from your first LLM call to agents in production — all five here
- My project: Hermes IDE | GitHub — an IDE for developers who ship with Claude Code and other AI coding tools
- Me: xgabriel.com | GitHub
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";
}
}
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;
};
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>
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.
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));
}
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 });
}
}
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 });
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.
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.
The full series is at
xgabriel.com/ai-in-typescript.



Top comments (0)