Vercel AI SDK
In this guide you'll build a support agent whose refund tool executes small refunds instantly but pauses for human approval on anything over $100, using @confirm/sdk. Total time: about ten minutes.
Before you start
- A Confirm account and API key. Sign in, open Dashboard → API Keys, create a key, and copy it (it's shown once).
- An Anthropic API key (or any model provider the AI SDK supports).
- Node 18+ and a project to work in.
npm install ai @ai-sdk/anthropic zod @confirm/sdkCONFIRM_API_KEY=cfm_live_...
ANTHROPIC_API_KEY=sk-ant-...Step 1: gate the dangerous tool
Define the tool with a threshold. Below it, execute immediately. Above it, call confirm.requests.createAndWait, which creates the request, emails the approver, and blocks until a human decides. Note the last line: we execute decision.effectivePayload, not our own arguments, because the approver may have edited the amount.
import { generateText, tool, stepCountIs } from "ai";
import { anthropic } from "@ai-sdk/anthropic";
import { z } from "zod";
import { ConfirmClient } from "@confirm/sdk";
const confirm = new ConfirmClient(); // reads CONFIRM_API_KEY
const refund = tool({
description:
"Refund a customer. Refunds over $100 require human approval and may take a while; " +
"a rejection is a valid outcome you should relay to the user.",
inputSchema: z.object({
customerId: z.string(),
amount: z.number().describe("Refund amount in USD"),
}),
execute: async ({ customerId, amount }) => {
if (amount <= 100) {
return issueRefund({ customerId, amount }); // your real refund logic
}
const decision = await confirm.requests.createAndWait({
summary: `Refund $${amount} to customer ${customerId}`,
payload: { customerId, amount },
notify: amount > 1000 ? "group:finance" : "support-lead@company.com",
reasoning: `Customer reported a duplicate charge; order history supports a refund of $${amount}.`,
recentActions: ["Read the support ticket", "Checked the customer's order history"],
ttlMinutes: 60, // fail safe if nobody answers
});
if (decision.status !== "APPROVED") {
return `Refund ${decision.status.toLowerCase()} by ${decision.resolvedByEmail ?? "nobody"}: ${decision.resolutionNote ?? "no reason given"}`;
}
return issueRefund(decision.effectivePayload as { customerId: string; amount: number });
},
});
const { text } = await generateText({
model: anthropic("claude-opus-4-8"),
system: "You are a support agent. Use the refund tool when a refund is warranted.",
prompt: "Customer #4821 was double-charged $180. Refund the duplicate charge.",
tools: { refund },
stopWhen: stepCountIs(5),
});
console.log(text);Step 2: run it and approve
npx tsx agent.tsHere's exactly what happens:
- The model decides to refund $180. That's over the threshold, so the tool calls Confirm.
- Your terminal goes quiet. The agent is paused inside the tool call, waiting.
- The email you set in
notifyreceives "Action required: Refund $180 to customer 4821". Open the link. - You'll see the payload. Try Edit payload first and change the amount to
90, add a note, then approve. createAndWaitreturns. Because you edited it,decision.effectivePayload.amountis 90, and that's what gets refunded. The model then writes its reply using the tool's return value.
Run it again and reject instead. The tool returns your rejection note as text, and the model explains to the user why the refund didn't happen. Nothing throws, nothing hangs, nothing executes.
Step 3 (recommended): gate every tool with one wrap
The per-tool version works, but you have to remember to add the check at every risky tool. guard() from the SDK wraps your whole tool map with policies instead, so a tool nobody wrote a rule for is still gated. Vercel tools are objects with an execute function, so add this ~15-line adapter once:
import { guard, ConfirmClient } from "@confirm/sdk";
const client = new ConfirmClient();
// Wrap Vercel AI SDK tools so guard() policies gate each tool's execute.
// Preserves each tool's schema and its (args, context) signature.
export function guardTools(tools: Record<string, any>, options: any) {
const guarded: Record<string, any> = {};
for (const [name, t] of Object.entries(tools)) {
const execute = t?.execute;
if (typeof execute !== "function") { guarded[name] = t; continue; }
guarded[name] = {
...t,
execute: (args: any, ctx: any) =>
guard({ [name]: (a: any) => execute(a, ctx) }, { ...options, client })[name](args),
};
}
return guarded;
}Now your tools stay plain, and one wrap adds the policies:
import { guardTools } from "./lib/confirm-guard";
const tools = guardTools(
{ searchOrders, refund }, // your plain tools, no approval logic inside
{
policies: [
{ when: (t) => t.name === "searchOrders", decision: "allow" }, // read-only
{
when: (t) => t.name === "refund" && t.args.amount > 100,
notify: (t) => (t.args.amount > 1000 ? "group:finance" : "support-lead@company.com"),
},
],
default: "require", // fail-closed: a tool without a matching allow still needs a human
},
);
await generateText({ model: anthropic("claude-opus-4-8"), tools, prompt, stopWhen: stepCountIs(5) });REJECTIONS
guard() throws ApprovalRejectedError / ApprovalExpiredError when the human says no. The AI SDK surfaces that as a tool error. If you'd rather the model re-plan, catch it inside the tool and return the note as text, exactly like the per-tool version in Step 1. Everything above is the whole setup: drop the adapter in lib/confirm-guard.ts and go.GOTCHA
maxDuration generously, or better: move approval-gated work to a background job and resume on the webhook instead of blocking.Next steps
- The SDK reference for
guard(),withApproval(), the client, and webhook verification. - Register a webhook to make resumption event-driven.
- Create approval groups so escalation targets teams, not inboxes.