Tool Approvals

By default, tools with an execute function run automatically when the model calls them. Use toolApproval on ToolLoopAgent to review, approve, or deny selected tool calls before they execute.

toolApproval is useful for tools that can modify data, spend money, execute code, send messages, access private data, or perform any other sensitive action.

toolApproval applies to tools executed by the AI SDK. Provider-executed tools run provider-side and do not use AI SDK tool approvals.

Statuses

Every approval rule returns one of these statuses, either as a string or as an object with a type field:

  • 'not-applicable': execute the tool normally without approval metadata. This is the default.
  • 'approved': record an automatic approval, then execute the tool.
  • 'denied': record an automatic denial and return a denied tool output.
  • 'user-approval': emit an approval request and wait for an explicit response.

For automatic approvals and denials, use the object form when you want to include a reason:

toolApproval: {
deleteFile: {
type: 'denied',
reason: 'Deleting files is disabled in this workspace',
},
}

Approval functions can also return undefined, which is treated the same as 'not-applicable'.

Require Approval for a Tool

Use a per-tool map when each tool has a simple policy.

import { ToolLoopAgent, tool } from 'ai';
import { z } from 'zod';
const agent = new ToolLoopAgent({
model: "xai/grok-4.5",
tools: {
runCommand: tool({
inputSchema: z.object({ command: z.string() }),
execute: async ({ command }) => runCommand(command),
}),
},
toolApproval: {
runCommand: 'user-approval',
},
});

When runCommand is called, the agent returns a tool-approval-request instead of executing the tool.

Decide Based on Tool Input

Use a per-tool approval function when the decision depends on the parsed tool input. The function receives the typed input plus toolCallId, messages, toolContext, and runtimeContext.

import { ToolLoopAgent, tool } from 'ai';
import { z } from 'zod';
const agent = new ToolLoopAgent({
model: "xai/grok-4.5",
tools: {
processPayment: tool({
inputSchema: z.object({
amount: z.number(),
recipient: z.string(),
}),
execute: async ({ amount, recipient }) =>
processPayment({ amount, recipient }),
}),
},
toolApproval: {
processPayment: async ({ amount }, { runtimeContext }) => {
if (runtimeContext.role !== 'admin') {
return { type: 'denied', reason: 'Only admins can send payments' };
}
return amount > 1000 ? 'user-approval' : undefined;
},
},
});

In this example, non-admin users are denied automatically, large payments require manual approval, and smaller admin payments execute normally.

Use One Policy for All Tools

Pass a function directly as toolApproval when approval depends on the full tool call, shared state across tools, or the complete tool set. This is called a GenericToolApprovalFunction.

const agent = new ToolLoopAgent({
model: "xai/grok-4.5",
tools: {
readFile: tool({
inputSchema: z.object({ path: z.string() }),
execute: async ({ path }) => readFile(path),
}),
deleteFile: tool({
inputSchema: z.object({ path: z.string() }),
execute: async ({ path }) => deleteFile(path),
}),
},
toolApproval: ({ toolCall }) => {
if (toolCall.dynamic) {
return 'user-approval';
}
if (toolCall.toolName === 'deleteFile') {
return 'user-approval';
}
return undefined;
},
});

The generic function receives:

  • toolCall: the full tool call, including toolName, toolCallId, input, and whether it is dynamic.
  • tools: all tools available to the model.
  • toolsContext: context for all tools.
  • messages: the messages sent to the model for the step that produced the tool call.
  • runtimeContext: the call's shared runtime context.

Configure Approval per Request

Because toolApproval is an agent setting, you can also return it from prepareCall. This is useful when approval policy depends on call options, tenant policy, or user permissions.

import { ToolLoopAgent, tool } from 'ai';
import { z } from 'zod';
const agent = new ToolLoopAgent({
model: "xai/grok-4.5",
callOptionsSchema: z.object({
canRunCommands: z.boolean(),
}),
prepareCall: ({ options, ...settings }) => ({
...settings,
toolApproval: {
runCommand: options.canRunCommands
? 'user-approval'
: { type: 'denied', reason: 'Command access is disabled' },
},
}),
tools: {
runCommand: tool({
inputSchema: z.object({ command: z.string() }),
execute: async ({ command }) => runCommand(command),
}),
},
});

Handle Manual Approvals

Manual approval requires two calls:

  1. Call agent.generate() or agent.stream() with toolApproval.
  2. Read the tool-approval-request from the result or UI stream.
  3. Ask the user or your approval system for a decision.
  4. Add a tool-approval-response to the messages.
  5. Call the agent again with the updated messages.
import { type ModelMessage, type ToolApprovalResponse } from 'ai';
const messages: ModelMessage[] = [{ role: 'user', content: 'Delete temp.txt' }];
const result = await agent.generate({ messages });
messages.push(...result.responseMessages);
const approvalResponses: ToolApprovalResponse[] = [];
for (const part of result.content) {
if (part.type === 'tool-approval-request' && !part.isAutomatic) {
approvalResponses.push({
type: 'tool-approval-response',
approvalId: part.approvalId,
approved: true,
reason: 'User confirmed the file can be deleted',
});
}
}
messages.push({
role: 'tool',
content: approvalResponses,
});
const finalResult = await agent.generate({ messages });

If approved, the tool runs on the second call. If denied, the model receives the denial and can respond without the tool result.

When a tool execution is denied, consider adding an instruction such as "When a tool execution is not approved, do not retry it" to prevent repeated approval requests for the same action.

Use with useChat

When streaming an agent to a chat UI, approval requests appear as tool parts with state: 'approval-requested'. Respond with addToolApprovalResponse.

'use client';
import { useChat } from '@ai-sdk/react';
import { lastAssistantMessageIsCompleteWithApprovalResponses } from 'ai';
export default function Chat() {
const { messages, addToolApprovalResponse } = useChat({
sendAutomaticallyWhen: lastAssistantMessageIsCompleteWithApprovalResponses,
});
return messages.map(message =>
message.parts.map(part => {
if (part.type !== 'tool-runCommand') {
return null;
}
if (part.state === 'approval-requested' && !part.approval.isAutomatic) {
return (
<div key={part.toolCallId}>
<button
onClick={() =>
addToolApprovalResponse({
id: part.approval.id,
approved: true,
})
}
>
Approve
</button>
<button
onClick={() =>
addToolApprovalResponse({
id: part.approval.id,
approved: false,
})
}
>
Deny
</button>
</div>
);
}
}),
);
}

Only call addToolApprovalResponse for manual approvals. Automatic approvals and denials already include approval state in the stream.

Security Considerations

Trust model

In the standard useChat pattern, the server rebuilds the conversation from the messages the client sends each turn. The server does not persist conversation state between requests. This means the message history is client-controlled input.

Tool approvals reconstructed from this history are re-validated before execution: the tool input is checked against the tool's schema, and the approval policy is re-evaluated. However, without additional protection, a client that crafts a valid-looking approval for a schema-conforming input can bypass the human-in-the-loop step.

If your tools perform sensitive operations (modifying data, spending money, calling external APIs, accessing private resources), use experimental_toolApprovalSecret to cryptographically bind approvals to the server that issued them.

Signing approvals with experimental_toolApprovalSecret

When you provide a secret, the server HMAC-signs each approval request at issuance and verifies the signature when the approval is replayed. A forged or tampered approval is rejected before the tool executes.

const result = await streamText({
model: "xai/grok-4.5",
tools: { deleteFile, runQuery },
toolApproval: { deleteFile: 'user-approval', runQuery: 'user-approval' },
experimental_toolApprovalSecret: process.env.TOOL_APPROVAL_SECRET,
messages,
});

The signature binds the approval to the exact tool name, tool call ID, and input arguments. Changing any of these after signing invalidates the approval.

Setting up the secret:

  1. Generate a high-entropy random string (at least 32 bytes):
    openssl rand -base64 32
  2. Store it as an environment variable accessible to all server instances:
    TOOL_APPROVAL_SECRET=your-generated-secret-here
  3. Pass it to generateText or streamText via experimental_toolApprovalSecret.

Every serverless instance that might handle a request needs the same secret, since one instance signs the approval and a different instance may verify it on the next turn.

Behavior when configured:

  • Approval requests without a valid signature are rejected (fail-closed)
  • No secret configured: approvals work as before (backward compatible)
  • The secret is never sent to the client or included in the stream

experimental_toolApprovalSecret is not yet supported on WorkflowAgent. For durable workflows, the workflow runtime provides its own persistence layer that can be used to verify approvals.

  • Use toolApproval with ToolLoopAgent, generateText, and streamText.
  • Author approval rules as code with Policy-Based Tool Approvals (@ai-sdk/policy-opa).
  • Use needsApproval only with WorkflowAgent, where approvals suspend and resume durable workflow execution.
  • Subagent tools cannot use toolApproval; see Subagents.