agent_signal
← All templates
Vercel AI SDK · 7.0.142 · TypeScript ✓ Tested

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 }.
Mocked vs real. MOCKED: the model only. MockLanguageModelV4 from ai/test returns two tool calls, then echoes the tool results it received. No provider and no API key. REAL: generateText, the tool loop, approval configuration, request/response handling, execution-denied outputs, zod input validation. Not tested: streamText / useChat with addToolApprovalResponse, experimental_toolApprovalSecret, provider-executed tools.

Code

approval.mjssrc
// 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);
package.jsonsrc
{
  "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"
}
run_output.txtsrc
== 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
LICENSEsrc
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.