Human approval and guardrails for AI agents

Build approval queues for consequential actions, policy checks in code, per-run credit and call budgets, and defences against prompt injection in tool outputs.

  • 50 min
  • Expert

Every guardrail in this lesson rests on one assumption: the model will sometimes be wrong, and sometimes it will have been misled on purpose. Not often, but at platform volume "not often" is a schedule. A control that depends on the model choosing to behave is not a control, so everything here lives in code the model cannot talk to.

The Associate courses introduced the pieces for one agent: a propose-then-approve pattern, a closure that counted credits, delimiters around untrusted text. This lesson turns them into platform infrastructure that every deployment inherits: a classification of every tool by consequence, an approval queue that cannot be replayed or redirected, a policy function that runs on every call, budgets enforced at several scopes, and a way to contain prompt injection that does not depend on spotting it.

Classify every tool by consequence

Before you can decide what needs a human, you need a vocabulary. Give every tool in the platform registry exactly one tier, and make the tier part of the tool's definition in code, not a comment.

TierWhat it doesDefault handlingExamples
readLooks something up or computes; no outbound side effectsRuns automatically, within budgetPhone, IBAN and email validation, number formatting
fetchReads content from an address the arguments chooseRuns if the host is allowed; output is untrustedURL to Markdown, Website to Text, screenshots
writeChanges state you can reverseRuns automatically only for an untainted run; otherwise approvalTag a ticket, add a CRM note
externalContacts a person, moves money or cannot be undoneAlways a proposal for a humanSend an email, issue a payout, lock an account
forbiddenExists in the codebase but no agent may call itRefusedChange a key, raise a budget, export a customer list

Every APIVerve endpoint is in the first two tiers. The MCP server marks every tool readOnlyHint: true, and none of them changes state in your account. But a tool that fetches a URL makes a request to an address the model chose, which is an outbound channel: a URL with data in its query string sends that data to whoever runs the server. That is why fetch is its own tier and not part of read.

The forbidden tier is there for a reason. Code that can adjust a budget or a scope will eventually be wrapped as a tool by someone trying to be helpful. Naming the tier makes the review question explicit.

Approval queues that hold up

The Associate version of an approval queue recorded a proposal and let an admin route run it. That is the right shape. At platform scale it needs five more properties, each closing a specific failure.

  1. Bind the approval to the exact arguments. Store a hash of the tool name and canonicalised arguments. At execution, recompute it. If anything changed between what the reviewer saw and what will run, refuse.
  2. Separate the requester from the approver. The agent's deployment identity can propose; only a human identity can decide, and never the same human whose request started the run if your policy requires two people.
  3. Expire proposals. A payout approved next Tuesday on last Monday's evidence is not the decision anyone made. Pending proposals lapse.
  4. Execute exactly once. Move the proposal from approved to executing with a compare-and-set, and pass the proposal id as the idempotency key to whatever performs the action. A double-clicked approve button or a retried worker then runs the action once.
  5. Attach evidence and re-check policy at execution. The reviewer sees the tool results that justify the action, and the policy function (next section) runs again at execution time, because limits and account state change while a proposal waits.

Evidence is where APIVerve's verification APIs earn their place. A payout agent proposing a transfer should attach a validation of the destination account, so the reviewer is not judging a raw string:

Try it liveIBAN Validator2 credits
GEThttps://api.apiverve.com/v1/ibanvalidator
curl "https://api.apiverve.com/v1/ibanvalidator?iban=GB82WEST12345698765432" \
  -H "x-api-key: YOUR_API_KEY"
Sign in or create a free account to run this with your own key.

isValid, countryCode and the checks object are what a reviewer needs beside the proposal. Bank and account breakdown fields are premium and may be null.

approvals.mjs
// Node 18+, ESM. An approval queue bound to exact arguments, with expiry and single execution.
import { createHash, randomUUID } from 'node:crypto';

const canonical = (v) =>
  Array.isArray(v) ? v.map(canonical)
  : v && typeof v === 'object' ? Object.fromEntries(Object.keys(v).sort().map((k) => [k, canonical(v[k])]))
  : v;
const digest = (tool, args) => createHash('sha256').update(JSON.stringify([tool, canonical(args)])).digest('hex');
const TTL_MS = 4 * 60 * 60 * 1000;

export const queue = new Map(); // a table with a unique id and conditional updates in production

export function propose(ctx, tool, args, evidence) {
  const id = randomUUID();
  queue.set(id, {
    id, tool, args, evidence, hash: digest(tool, args), status: 'pending',
    tenantId: ctx.tenantId, requestedBy: ctx.deploymentId, startedBy: ctx.userId, runId: ctx.runId,
    expiresAt: Date.now() + TTL_MS,
  });
  return { isError: false, content: [{ type: 'text', text: `Proposed ${tool} as ${id}. A person will decide; do not propose it again.` }] };
}

export function decide(id, reviewer, approve) {
  const p = queue.get(id);
  if (!p || p.status !== 'pending') throw new Error('No pending proposal with that id');
  if (Date.now() > p.expiresAt) { p.status = 'expired'; throw new Error('Proposal expired'); }
  if (!reviewer.isHuman || reviewer.id === p.startedBy) throw new Error('Whoever started the run cannot approve it');
  Object.assign(p, { status: approve ? 'approved' : 'declined', reviewer: reviewer.id, decidedAt: Date.now() });
}

export async function executeApproved(id, handlers, policy) {
  const p = queue.get(id);
  if (!p || p.status !== 'approved') throw new Error('Not approved');
  p.status = 'executing';                                   // compare-and-set in your database
  if (digest(p.tool, p.args) !== p.hash) { p.status = 'failed'; throw new Error('Arguments changed after approval'); }
  const verdict = policy({ tenantId: p.tenantId, atExecution: true }, { name: p.tool, args: p.args });
  if (verdict.decision === 'deny') { p.status = 'failed'; throw new Error(`Policy now denies: ${verdict.reason}`); }
  const result = await handlers[p.tool](p.args, { idempotencyKey: p.id });
  Object.assign(p, { status: 'done', executedAt: Date.now() });
  return result;
}

Two habits keep the queue useful. Reviewers see the evidence and the run's trace, not a one-line summary the agent wrote about itself, because the agent is the thing being checked. And there is no "approve all" button. If reviewers want one, the agent proposes too much, and the fix is a better policy, not a faster click.

Check yourself

A reviewer approves a payout proposal for 1,200 EUR. Before it executes, a bug in the agent's retry path updates the stored arguments to 12,000 EUR. Which property of the queue above stops the transfer?

Run every example on your own key. Free plan, no card.

Scaling up?

Volume pricing, custom SLAs, and dedicated support for high-traffic teams.

Contact sales