Tested on Vercel AI SDK (ai 7.0.142, pinned in package.json + package-lock.json)
Tool approval with generateText
generateText with the v7 toolApproval option: refunds over $100 pause as tool-approval-request, a human approves or denies, and the run resumes. Covers $50 auto-approved, $500 approved and $500 denied.
What it shows
- toolApproval: generateText({ tools, toolApproval: { refund: (input) => ... }, stopWhen: stepCountIs(5) }) returns { type: "user-approval", reason } for refunds over $100 and "approved" otherwise.
- Pause: A paused call appears in result.content as { type: "tool-approval-request", approvalId, toolCall, reason }. The tool has not executed; lookupOrder in the same step did run.
- Resume / deny: Append ...result.response.messages plus a tool message with { type: "tool-approval-response", approvalId, approved, reason } and call generateText again. If denied, the model receives { type: "execution-denied", reason }.
Code
// Vercel AI SDK (ai@7.0.142) tool approval: toolApproval -> tool-approval-request -> tool-approval-response -> resume.
// MOCK: only the model (MockLanguageModelV4 from 'ai/test'). generateText, tool loop, approval logic are real SDK code.
import { generateText, tool, stepCountIs } from 'ai';
import { MockLanguageModelV4 } from 'ai/test';
import { z } from 'zod';
const executed = [];
const usage = { inputTokens: { total: 1, noCache: 1, cacheRead: 0, cacheWrite: 0 }, outputTokens: { total: 1, text: 1, reasoning: 0 } };
const finish = (u) => ({ unified: u, raw: u });
// Scripted model: first call -> two tool calls; once tool results/denials are in the prompt -> text.
function makeModel(amount) {
return new MockLanguageModelV4({
doGenerate: async ({ prompt }) => {
const toolMsgs = prompt.filter((m) => m.role === 'tool').flatMap((m) => m.content);
const results = toolMsgs.filter((p) => p.type === 'tool-result');
if (results.length) {
return { content: [{ type: 'text', text: 'Model saw: ' + JSON.stringify(results.map((r) => [r.toolName, r.output])) }],
finishReason: finish('stop'), usage, warnings: [] };
}
return { content: [
{ type: 'tool-call', toolCallId: 'call_lookup', toolName: 'lookupOrder', input: JSON.stringify({ orderId: '42' }) },
{ type: 'tool-call', toolCallId: 'call_refund', toolName: 'refund', input: JSON.stringify({ orderId: '42', amount }) },
], finishReason: finish('tool-calls'), usage, warnings: [] };
},
});
}
const tools = {
lookupOrder: tool({ description: 'Look up an order', inputSchema: z.object({ orderId: z.string() }),
execute: async ({ orderId }) => (executed.push(`lookup ${orderId}`), { orderId, total: 250 }) }),
refund: tool({ description: 'Refund an order', inputSchema: z.object({ orderId: z.string(), amount: z.number() }),
execute: async ({ orderId, amount }) => (executed.push(`refund ${orderId} $${amount}`), { refunded: amount }) }),
};
// v7: approval is configured on generateText/streamText (tool-level needsApproval is deprecated)
const toolApproval = {
refund: (input) => (input.amount > 100 ? { type: 'user-approval', reason: `refund over $100 ($${input.amount})` } : 'approved'),
};
async function scenario(label, amount, decision) {
executed.length = 0;
console.log(`== ${label} ==`);
const model = makeModel(amount);
const messages = [{ role: 'user', content: 'refund order 42' }];
const opts = { model, tools, toolApproval, stopWhen: stepCountIs(5) };
let r = await generateText({ ...opts, messages });
const all = r.content.filter((p) => p.type === 'tool-approval-request');
console.log(' approval requests (isAutomatic):', all.map((q) => `${q.toolCall.toolName}:${!!q.isAutomatic}`));
const requests = all.filter((q) => !q.isAutomatic); // automatic approvals also appear as requests
console.log(' content types:', r.content.map((p) => p.type).join(', '));
console.log(' executed so far:', executed);
if (!requests.length) { console.log(' final text:', r.text); return; }
for (const q of requests) console.log(` approval request ${q.approvalId}: ${q.toolCall.toolName}(${JSON.stringify(q.toolCall.input)}) reason=${q.reason}`);
messages.push(...r.response.messages,
{ role: 'tool', content: requests.map((q) => ({ type: 'tool-approval-response', approvalId: q.approvalId, approved: decision,
reason: decision ? 'ok by manager' : 'refund too large' })) });
console.log(` -> human responds approved=${decision}`);
r = await generateText({ ...opts, messages });
console.log(' resumed content types:', r.content.map((p) => p.type).join(', '));
console.log(' executed after resume:', executed);
console.log(' final text:', r.text);
console.log(' model calls made:', model.doGenerateCalls.length);
}
await scenario('small refund ($50): auto-approved', 50);
await scenario('large refund ($500): APPROVED', 500, true);
await scenario('large refund ($500): DENIED', 500, false);
{
"name": "vercel-ai-sdk-tool-approval",
"version": "1.0.0",
"description": "",
"main": "index.js",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1",
"start": "node approval.mjs"
},
"keywords": [],
"author": "",
"license": "MIT",
"dependencies": {
"ai": "7.0.142",
"zod": "4.6.7"
},
"type": "module"
}
== small refund ($50): auto-approved ==
approval requests (isAutomatic): [ 'refund:true' ]
content types: tool-call, tool-call, tool-result, tool-approval-request, tool-approval-response, tool-result, text
executed so far: [ 'lookup 42', 'refund 42 $50' ]
final text: Model saw: [["lookupOrder",{"type":"json","value":{"orderId":"42","total":250}}],["refund",{"type":"json","value":{"refunded":50}}]]
== large refund ($500): APPROVED ==
approval requests (isAutomatic): [ 'refund:false' ]
content types: tool-call, tool-call, tool-result, tool-approval-request
executed so far: [ 'lookup 42' ]
approval request aitxt-D1VU8ZZWN33brXX5xpEcmi5V: refund({"orderId":"42","amount":500}) reason=refund over $100 ($500)
-> human responds approved=true
resumed content types: text
executed after resume: [ 'lookup 42', 'refund 42 $500' ]
final text: Model saw: [["lookupOrder",{"type":"json","value":{"orderId":"42","total":250}}],["refund",{"type":"json","value":{"refunded":500}}]]
model calls made: 2
== large refund ($500): DENIED ==
approval requests (isAutomatic): [ 'refund:false' ]
content types: tool-call, tool-call, tool-result, tool-approval-request
executed so far: [ 'lookup 42' ]
approval request aitxt-zzn2oZ1tVYDUhe0vtC6clPwY: refund({"orderId":"42","amount":500}) reason=refund over $100 ($500)
-> human responds approved=false
resumed content types: text
executed after resume: [ 'lookup 42' ]
final text: Model saw: [["lookupOrder",{"type":"json","value":{"orderId":"42","total":250}}],["refund",{"type":"execution-denied","reason":"refund too large"}]]
model calls made: 2
MIT License Copyright (c) 2026 Agent Signal Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
Upgrade gotchas
- Tool-level needsApproval is deprecated in v7; use the generateText/streamText toolApproval option, which takes precedence.
- Automatic approvals also appear as tool-approval-request parts with isAutomatic: true. Filter on !isAutomatic or you will ask a human about calls that already ran (sending a response without approved gives AI_InvalidPromptError).
- On resume, pass the full history (user message plus response.messages) and the tool message with the decision; approved is required.
- The approval reason reaches the model only for denials (inside execution-denied).
- Reply with approvalId, not toolCallId. If clients can tamper with history, set experimental_toolApprovalSecret so requests are HMAC-signed.
Free starter pack
The approval-flow starter pack
Tested human-in-the-loop approval starters for LangGraph, OpenAI Agents SDK, Vercel AI SDK and Google ADK, plus the weekly Agent Signal newsletter. Free.
FAQ
Is needsApproval still the way to do tool approval in AI SDK v7?
No. It is deprecated in v7; use the toolApproval option on generateText or streamText.
How do I resume after approval?
Call generateText again with the full history, response.messages and a tool message containing tool-approval-response with approvalId and approved.
Which versions was it tested on?
ai 7.0.142 and zod 4.6.7 on Node 20.19.2, Oct 15, 2026. MIT-licensed.