
Build an Agent with Human Approval Using AI SDK 6
AI SDK 6 just shipped ToolLoopAgent and built-in human-in-the-loop approval. Here's how to build an agent that pauses for a human before it runs a risky tool.
Key takeaways
- AI SDK 6 adds ToolLoopAgent and a needsApproval flag that pauses the agent loop for a human decision before a risky tool runs.
- needsApproval can be a function that receives the parsed tool input, so you can gate only refunds over a threshold instead of every call.
- Approval state lives in the message history, so persist the approval-requested parts and approval.id intact or the resume cannot match the decision.
- Put the real authorization check in needsApproval on the server, since the UI buttons are convenience and a crafted client request can bypass them.
Vercel shipped AI SDK 6 this month, and the two pieces I care about most are ToolLoopAgent and built-in tool approval. The first one finally gives you a real agent loop without writing your own while-loop around generateText. The second one lets you gate any tool behind a human decision with a single flag. If you've ever built an agent that can write to your database, send an email, or run a shell command, you already know why that second feature matters.
The thing about an agentic loop is that the model decides when to call a tool, and it will call tools you did not expect on inputs you did not expect. Most of the time that's fine. The agent looks up the weather, reads a file, queries some read-only endpoint, nobody gets hurt. The trouble starts when one of the tools in the box does something you can't take back. Deleting a row. Charging a card. Pushing to production. For those, "the model decided to" is not a sentence you want to say in an incident review.
Human-in-the-loop (HITL) is the answer, and before AI SDK 6 you had to build the pause-and-resume machinery yourself: intercept the tool call, store the pending state somewhere, surface it to a UI, wait, then feed the decision back into the conversation. It worked but it was fiddly and easy to get subtly wrong. AI SDK 6 moves that into the SDK. You mark a tool with needsApproval, and the loop pauses itself, emits an approval request, and resumes once a human answers. In this tutorial we'll build a small agent with one safe tool and one dangerous tool, wire up the approval flow on both the server and a React client, and talk about where the rough edges still are.
Everything below is against AI SDK 6 (the ai package, version 6.x). If you're still on 5.x, the ToolLoopAgent class and the needsApproval option do not exist yet, so this won't work as written.
What you'll need
- Node 20 or newer.
- An AI SDK 6 install. The core package is
ai, and you'll want a provider package plus the React bindings for the client half. - A model provider key. I'll use Anthropic here via
@ai-sdk/anthropic, but any supported provider works. Set it asANTHROPIC_API_KEYin your environment. - A Next.js app (App Router) if you want the UI piece. The server-side agent works fine on its own, but the approval UX is much nicer in a browser.
npm install ai @ai-sdk/anthropic @ai-sdk/react zod
A quick note on zod: AI SDK uses it for tool input schemas, and v6 expects a recent zod. If you see odd type errors on inputSchema, check your zod version before anything else.
Defining the tools
Let's start with the tools, because they're where the approval decision lives. I'll define two. The first reads a customer record, which is harmless. The second issues a refund, which moves real money and is exactly the kind of thing I want a human to sign off on.
import { tool } from 'ai';
import { z } from 'zod';
export const lookupCustomer = tool({
description: 'Look up a customer by their ID and return their account details.',
inputSchema: z.object({
customerId: z.string().describe('The customer ID, e.g. cus_123'),
}),
execute: async ({ customerId }) => {
// Pretend this hits your billing provider.
return {
customerId,
name: 'Ada Lovelace',
lastCharge: { amount: 4200, currency: 'usd', id: 'ch_789' },
};
},
});
Nothing special there. No needsApproval, so the agent runs it automatically whenever the model asks for it. Now the dangerous one:
export const issueRefund = tool({
description: 'Issue a refund to a customer for a specific charge.',
inputSchema: z.object({
chargeId: z.string().describe('The charge ID to refund, e.g. ch_789'),
amount: z.number().describe('Amount to refund in cents'),
}),
needsApproval: true,
execute: async ({ chargeId, amount }) => {
// Pretend this calls your payment provider's refund endpoint.
return { refunded: true, chargeId, amount };
},
});
That one extra line, needsApproval: true, is the whole feature. The tool still keeps its execute function. The SDK just refuses to call it until a human says yes.
needsApproval also takes a function if a blanket gate is too coarse. This is the version I actually reach for in production, because most refunds are small and routine and I don't want to bother a human for every five-dollar credit. Only the big ones need eyes:
export const issueRefund = tool({
description: 'Issue a refund to a customer for a specific charge.',
inputSchema: z.object({
chargeId: z.string(),
amount: z.number().describe('Amount to refund in cents'),
}),
// Only refunds over $1,000 need a human.
needsApproval: async ({ amount }) => amount > 100_000,
execute: async ({ chargeId, amount }) => {
return { refunded: true, chargeId, amount };
},
});
The function receives the same parsed input the tool would run with, so you can branch on anything the model passed in. It can be async, which means you can check a database, look up the requesting user's permissions, or read a feature flag before deciding. The return is just a boolean: true means pause for approval, false means run it now.
Building the agent
With the tools defined, the agent itself is short. ToolLoopAgent runs the full loop for you: it calls the model, executes whatever tools the model asked for, feeds the results back, and repeats until the model stops asking for tools (up to 20 steps by default).
import { ToolLoopAgent } from 'ai';
import { anthropic } from '@ai-sdk/anthropic';
import { lookupCustomer, issueRefund } from './tools';
export const supportAgent = new ToolLoopAgent({
model: anthropic('claude-sonnet-4.5'),
instructions:
'You are a billing support agent. Look up customers and issue refunds when asked. Always confirm the charge before refunding.',
tools: {
lookupCustomer,
issueRefund,
},
});
If you just want to see it run end to end with no approval gate (set needsApproval aside for a second), this is all you need:
const result = await supportAgent.generate({
prompt: 'Refund the last charge for customer cus_123.',
});
console.log(result.text);
console.log(result.steps); // every tool call and result along the way
generate runs to completion and hands back a GenerateTextResult. There's also stream, which gives you a StreamTextResult with a textStream you can iterate, plus the tool events as they happen. For a HITL agent you almost always want streaming, because the approval request is something you need to surface to a human in real time rather than blocking a request handler indefinitely.
The important thing to understand about approval and the loop: when the agent hits a tool that needs approval, it does not block your server process waiting for a human. It pauses the loop and emits the tool call in an approval-requested state, then returns. The conversation is now in a half-finished state, and you resume it later by sending the human's decision back as part of the message history. That design is what makes this work over HTTP, where the request that started the agent is long gone by the time someone clicks Approve.
Surfacing the approval to a UI
Here's the server route. It's a normal Next.js App Router handler that streams the agent's output back to the client as a UI message stream.
// app/api/chat/route.ts
import { supportAgent } from '@/lib/agent';
export async function POST(req: Request) {
const { messages } = await req.json();
const result = supportAgent.stream({ messages });
return result.toUIMessageStreamResponse();
}
Notice the route takes messages, not a single prompt. That's deliberate. The full conversation, including any earlier approval decisions, lives in the message history, and the client sends it all back on each turn. The server stays stateless, which is what you want.
When the model calls the refund tool and the gate trips, the streamed message gets a tool part in the approval-requested state. That part carries the input the model proposed and an approval.id you'll need to answer it. On the client, you check for that state and render buttons.
// app/chat.tsx
'use client';
import { useChat } from '@ai-sdk/react';
import {
DefaultChatTransport,
lastAssistantMessageIsCompleteWithApprovalResponses,
} from 'ai';
import { useState } from 'react';
export default function Chat() {
const { messages, sendMessage, addToolApprovalResponse } = useChat({
transport: new DefaultChatTransport({ api: '/api/chat' }),
// Auto-resume once every pending approval has an answer.
sendAutomaticallyWhen: lastAssistantMessageIsCompleteWithApprovalResponses,
});
const [input, setInput] = useState('');
return (
<div>
{messages.map((m) => (
<div key={m.id}>
<strong>{m.role}: </strong>
{m.parts.map((part, i) => {
if (part.type === 'text') {
return <span key={i}>{part.text}</span>;
}
if (part.type === 'tool-issueRefund') {
switch (part.state) {
case 'approval-requested':
return (
<div key={part.toolCallId}>
<p>
Refund ${(part.input.amount / 100).toFixed(2)} on charge{' '}
{part.input.chargeId}?
</p>
<button
onClick={() =>
addToolApprovalResponse({
id: part.approval.id,
approved: true,
})
}
>
Approve
</button>
<button
onClick={() =>
addToolApprovalResponse({
id: part.approval.id,
approved: false,
})
}
>
Deny
</button>
</div>
);
case 'output-available':
return (
<div key={part.toolCallId}>
Refund issued: {JSON.stringify(part.output)}
</div>
);
case 'output-denied':
return (
<div key={part.toolCallId}>Refund was denied.</div>
);
}
}
})}
</div>
))}
<form
onSubmit={(e) => {
e.preventDefault();
if (input.trim()) {
sendMessage({ text: input });
setInput('');
}
}}
>
<input
value={input}
onChange={(e) => setInput(e.target.value)}
placeholder="Ask the support agent..."
/>
</form>
</div>
);
}
A few things worth pointing out in that component. The tool part type is tool-issueRefund, which is the literal tool- prefix plus the key you used in the agent's tools map. If you rename the tool in the map, you rename this string too, so keep them in sync. The three states you handle are approval-requested (waiting on a human), output-available (approved and run, here's the result), and output-denied (the human said no, the tool never ran). The safe lookupCustomer tool will go straight to output-available and never show an approval prompt, which is the whole point of leaving needsApproval off it.
Resuming on approve or deny
The resume is the part that surprised me the first time, because there's almost nothing to it. Calling addToolApprovalResponse({ id, approved }) records the decision against the pending tool part. It does not, on its own, send anything to the server. The actual resume is driven by sendAutomaticallyWhen.
The helper lastAssistantMessageIsCompleteWithApprovalResponses checks whether every approval request in the latest assistant message now has an answer. The moment the last pending one gets a decision, the hook ships the updated message history back to your route, the server runs the agent again, and now the loop has the human's answers in hand. For approved tools it calls execute and continues. For denied tools it skips execute and tells the model the call was denied, so the model can apologize, try a different approach, or wrap up.
If you have several tools awaiting approval in one turn (the model can request more than one), the auto-send waits until all of them are answered before resuming. That batching is usually what you want, but it's worth knowing, because a half-answered turn just sits there until the human finishes.
If you'd rather drive the resume yourself instead of using the auto-send helper, you can leave sendAutomaticallyWhen off and call sendMessage() manually after collecting the approvals. I've only needed that for a wizard-style UI where I wanted the human to review a batch of decisions and submit them together with an extra confirmation step.
Gotchas
A few things bit me or my team while building this out.
The tool part type string is easy to get wrong. It's tool- plus the exact key in your tools object, not the tool's description and not the variable name you imported. A typo here fails silently: the branch just never renders, and you stare at an agent that seems frozen. When an approval prompt doesn't show up, check this string first.
Approval state lives in the message history, not on your server. That's a feature, but it means if you're persisting conversations, you need to store the messages with the approval-requested parts intact, including the approval.id. Drop or mangle those and the resume can't match the decision to the pending call. There's an open issue in the vercel/ai repo about a "no tool invocation found" error after approving when the message round-trip loses a part, so treat the message array as the source of truth and round-trip it faithfully.
needsApproval as a function runs on the server, with the model's proposed input. Do not put the actual authorization check only in the UI. The buttons are convenience, not security. If a tool genuinely must not run without permission, the gate belongs in needsApproval and the real side effect belongs in execute, both server-side, where a crafted client request can't bypass them.
Denial is not an error. When a human denies a tool, the model is told the call was denied and the loop keeps going. That's usually right, but it means a denied refund won't throw, it'll just lead the agent to say something like "I wasn't able to process that refund." If you want denial to hard-stop the whole interaction, you have to handle that yourself, either with a stopWhen condition or by checking for the output-denied state and ending the conversation in your own UI logic.
The default step cap is 20. An agent that keeps proposing tools, getting denied, and trying again can chew through steps fast. Set stopWhen explicitly for anything you'd run in production rather than relying on the default, and watch result.steps while you're developing so you can see what the loop is actually doing.
Wrapping up
The honest summary is that AI SDK 6 took the most annoying part of building a safe agent and made it a one-line flag. The pause-and-resume dance that used to be custom plumbing is now needsApproval on the tool, an approval-requested branch in your UI, and an auto-send helper that resumes the loop. The part you still own, and should think hard about, is which tools deserve the gate and what the function form should check. That's a judgment call about your own blast radius, and no SDK is going to make it for you. Start by gating anything that spends money, deletes data, or talks to the outside world, and loosen from there once you trust the agent.