# Vercel AI SDK 7.0.142: 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.

Status: tested 2026-10-15 · Node 20.19.2 (tested) · License: MIT · Tier: Free
Download: https://tryagentsignal.com/downloads/vercel-ai-sdk-tool-approval-starter.zip

## 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.

## 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.

## 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.

## Code

### approval.mjs

```javascript
// 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.json

```
{
  "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.txt

```
== 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

```
