In AI SDK 7, set toolApproval: { refundCharge: "user-approval" } on streamText or ToolLoopAgent. The tool call then reaches useChat as a tool part in the approval-requested state instead of running. Render that part as an approval card, call addToolApprovalResponse from approve or reject, and let sendAutomaticallyWhen resume the run. Sign approvals with experimental_toolApprovalSecret for anything that spends money.
How the AI SDK pauses for approval#
The AI SDK's Tool Approvals page describes four statuses an approval rule can return: not-applicable (run normally), approved, denied and user-approval. Only user-approval waits for a person. The rule can be a string per tool, a function of the tool's input (approve refunds under $100 automatically, ask above), or one function for every tool.
On the client, the chatbot tool usage page lists the tool part states a waiting call moves through. Two details matter for the UI:
- Automatic decisions stream too. A part can be
approval-requestedwithapproval.isAutomaticset. Show those as a quiet row, never as a card with buttons. - The reason is separate from the note.
approval.requestReasonis why the server asked. The note the person types on reject goes back asreasonand the model sees it with the denial.
Older guides use needsApproval on the tool. In AI SDK 7 that option belongs to WorkflowAgent only; toolApproval is the setting for streamText, generateText and ToolLoopAgent.
From part state to card#
| AI SDK tool part state | What the person sees | Approval card props |
|---|---|---|
input-streaming |
Nothing yet, or a tool row that says it is preparing | Not rendered |
approval-requested, manual |
The card with Approve and Reject | status="pending", onApprove, onReject |
approval-requested, automatic |
A one-line receipt, no buttons | Use a tool call row instead |
approval-responded |
The receipt while the tool runs | status from approval.approved |
output-available |
The receipt, with the tool's result below it | status="approved" |
output-denied |
The receipt with the note | status="rejected", receipt from approval.reason |
output-error |
The tool failed after approval | A tool call row in its error state |
The code#
The server marks the refund tool as needing a person and signs every approval, so a modified client cannot forge one.
import { tool, type InferUITools, type UIMessage } from "ai";
import { z } from "zod";
export const tools = {
refundCharge: tool({
description: "Refund a Stripe charge in full or in part",
inputSchema: z.object({
chargeId: z.string(),
customer: z.string(),
amountCents: z.number().int().positive(),
}),
execute: async ({ chargeId, amountCents }) => ({ refundId: `re_${chargeId.slice(3)}`, amountCents }),
}),
};
export type RefundMessage = UIMessage<never, never, InferUITools<typeof tools>>;import { convertToModelMessages, createUIMessageStreamResponse, streamText, toUIMessageStream } from "ai";
import { tools, type RefundMessage } from "../../../lib/refund-agent";
export async function POST(req: Request) {
const { messages }: { messages: RefundMessage[] } = await req.json();
const result = streamText({
model: "openai/gpt-5.5",
system: "When a tool call is not approved, do not retry it. Ask what to change.",
messages: await convertToModelMessages(messages),
tools,
// Refunds under $100 go through; anything larger waits for a person.
toolApproval: {
refundCharge: ({ amountCents }) => (amountCents < 10_000 ? "approved" : "user-approval"),
},
experimental_toolApprovalSecret: process.env.TOOL_APPROVAL_SECRET,
});
return createUIMessageStreamResponse({ stream: toUIMessageStream({ stream: result.stream }) });
}The client maps each refund part to the card. The callbacks return promises, so the card shows its own pending state.
"use client";
import { useChat } from "@ai-sdk/react";
import { lastAssistantMessageIsCompleteWithApprovalResponses } from "ai";
import { ApprovalCard } from "@/components/pixilui/approval-card";
import type { RefundMessage } from "../lib/refund-agent";
const usd = (cents: number) => (cents / 100).toLocaleString("en-US", { style: "currency", currency: "USD" });
export function RefundChat() {
const { messages, addToolApprovalResponse } = useChat<RefundMessage>({
sendAutomaticallyWhen: lastAssistantMessageIsCompleteWithApprovalResponses,
});
return messages.flatMap((message) =>
message.parts.map((part) => {
if (part.type !== "tool-refundCharge" || part.state === "input-streaming" || part.state === "output-error") return null;
if (part.approval?.isAutomatic) return null; // auto-approved: show a tool call row, not a card
const { chargeId, customer, amountCents } = part.input;
const decided = part.state === "approval-requested" ? "pending" : part.approval?.approved === false || part.state === "output-denied" ? "rejected" : "approved";
const approvalId = part.approval?.id;
return (
<ApprovalCard
key={part.toolCallId}
title={`Refund ${usd(amountCents)} to ${customer}`}
risk={amountCents >= 100_000 ? "high" : "medium"}
items={[
{ label: "Charge", value: chargeId, numeric: true },
{ label: "Amount", value: usd(amountCents), numeric: true },
]}
source={part.approval?.requestReason ?? "Support agent"}
status={decided}
receipt={decided === "rejected" && part.approval?.reason ? `Not refunded: ${part.approval.reason}` : undefined}
labels={{ approve: "Approve refund" }}
onApprove={async () => {
if (approvalId) await addToolApprovalResponse({ id: approvalId, approved: true });
}}
onReject={async (note) => {
if (approvalId) await addToolApprovalResponse({ id: approvalId, approved: false, reason: note || undefined });
}}
/>
);
}),
);
}Install the card with npx shadcn@latest add @pixilui/approval-card. Set TOOL_APPROVAL_SECRET to a random 32-byte string on every server instance.
What the card covers that the docs example leaves to you#
The AI SDK example renders two plain buttons. The card adds the parts that decide whether a person approves the right thing:
- Risk.
risk="high"asks for a second press before it approves, so a stray Enter does not move $2,400. - Evidence rows. Amounts and IDs set in tabular figures, so the person compares the number, not the sentence.
- Pending and failure. While
addToolApprovalResponseis in flight the buttons hold; if it throws, the card says nothing changed and offers the buttons again. - A receipt. Once decided, the card folds into one line, so a thread with five approvals stays readable.
When the framework already ships a UI#
The AI SDK itself ships no approval component; its docs render plain buttons. AI Elements, Vercel's component set for the AI SDK, has a Confirmation component, and its site says the project moves into shadcn/ui at the end of November 2026. If you already use it and it fits, keep it. The AI Elements migration doc maps Confirmation to the approval card when you want risk levels, a confirm step for high risk and a folded receipt.
When not to use this#
- The tool has no side effect. Reads and searches should run; asking for approval on every lookup trains people to click Approve without reading.
- The decision can be a rule. If every refund under $100 is fine, return
"approved"from the rule instead of asking. - Many actions at once. Ten pending approvals in one message is a queue, not a chat. Collect them in an inbox and approve from there.
Questions#
Does needsApproval still work in AI SDK 7?
Only with WorkflowAgent, where an approval suspends a durable workflow. For streamText, generateText and ToolLoopAgent the AI SDK docs use toolApproval, with user-approval as the status that waits for a person.
Why does nothing happen after I click Approve?
addToolApprovalResponse records the decision on the message; it does not send it. Set sendAutomaticallyWhen: lastAssistantMessageIsCompleteWithApprovalResponses on useChat, or call sendMessage yourself, so the run continues.
Can a user fake an approval from the browser?
In the useChat pattern the client sends the whole message history each turn, so the AI SDK docs warn that a forged approval could pass. Set experimental_toolApprovalSecret and the server signs each request and rejects any approval it did not issue.
Does the note on reject reach the model?
Yes. A reason sent with a denial is included in the execution-denied result the model sees. A reason sent with an approval is stored on the part but not added to the prompt.
Related#
- Human-in-the-loop approval for AI agents: what a good approval step shows, framework aside.
- Human approval with LangGraph: the same card on an
interrupt(). - Human approval with the OpenAI Agents SDK: the same card on
needsApprovalandRunState. - Tool call UI with the Vercel AI SDK: the row for calls that do not need a person.
Sources#
- AI SDK: Tool Approvals, read 4 November 2026
- AI SDK UI: Chatbot Tool Usage, read 4 November 2026
- AI SDK: WorkflowAgent, read 4 November 2026
- AI Elements, read 4 November 2026