The tool approval flow allows you to require user approval before executing sensitive tools, giving users control over actions like sending emails, making purchases, or deleting data. A tool call moves through the ToolCallState lifecycle:
awaiting-input — Tool call started, no arguments yet
input-streaming — Arguments arriving incrementally
input-complete — All arguments received
approval-requested — Waiting for user approval (only if needsApproval: true)
approval-responded — User approved or denied
After approval-responded the call executes (if approved). Although complete exists in the ToolCallState union, the runtime never transitions the tool-call part to it — the result surfaces as a populated part.output plus a sibling tool-result part whose own state is complete or error.
When a tool requires approval, the typical flow is:
Model calls the tool
Tool execution is paused
User is prompted to approve or deny
Tool executes (if approved) or is cancelled (if denied)
Conversation continues with the result
Tools can be marked as requiring approval by setting needsApproval: true in the definition:
import { toolDefinition } from "@tanstack/ai";
import { z } from "zod";
// Step 1: Define tool with approval requirement
const sendEmailDef = toolDefinition({
name: "send_email",
description: "Send an email to a recipient",
inputSchema: z.object({
to: z.string().email(),
subject: z.string(),
body: z.string(),
}),
outputSchema: z.object({
success: z.boolean(),
messageId: z.string(),
}),
needsApproval: true, // This tool requires approval
});
// Step 2: Create server implementation
const sendEmail = sendEmailDef.server(async ({ to, subject, body }) => {
// Only executes if approved
await emailService.send({ to, subject, body });
return { success: true, messageId: "..." };
});On the server, tools with needsApproval: true will pause execution and wait for approval:
import { chat, toServerSentEventsResponse } from "@tanstack/ai";
import { openaiText } from "@tanstack/ai-openai";
import { sendEmail } from "./tools";
export async function POST(request: Request) {
const { messages } = await request.json();
const stream = chat({
adapter: openaiText("gpt-5.5"),
messages,
tools: [sendEmail],
});
return toServerSentEventsResponse(stream);
}The client receives approval requests and can respond:
import { useChat, fetchServerSentEvents } from "@tanstack/ai-react";
function ChatComponent() {
const { messages, sendMessage, addToolApprovalResponse } = useChat({
connection: fetchServerSentEvents("/api/chat"),
});
return (
<div>
{messages.map((message) => (
<div key={message.id}>
{message.parts.map((part) => {
// Check for approval requests
if (
part.type === "tool-call" &&
part.state === "approval-requested" &&
part.approval
) {
return (
<div key={part.id} className="approval-prompt">
<p>Approve: {part.name}</p>
<pre>{JSON.stringify(part.input, null, 2)}</pre>
<button
onClick={() =>
addToolApprovalResponse({
id: part.approval!.id,
approved: true,
})
}
>
Approve
</button>
<button
onClick={() =>
addToolApprovalResponse({
id: part.approval!.id,
approved: false,
})
}
>
Deny
</button>
</div>
);
}
// ... render other parts
})}
</div>
))}
</div>
);
}Here's a more complete approval UI component:
import type { ToolCallPart } from "@tanstack/ai-client";
function ApprovalPrompt({
part,
onApprove,
onDeny,
}: {
part: ToolCallPart;
onApprove: () => void;
onDeny: () => void;
}) {
// When tools are passed via `clientTools(...)`, `part.input` is the
// parsed, fully-typed argument object. Otherwise parse `part.arguments`.
const args = part.input ?? JSON.parse(part.arguments);
return (
<div className="border border-yellow-500 rounded-lg p-4 bg-yellow-50">
<div className="font-semibold mb-2">
🔒 Approval Required: {part.name}
</div>
<div className="text-sm text-gray-600 mb-4">
<pre className="bg-gray-100 p-2 rounded text-xs overflow-x-auto">
{JSON.stringify(args, null, 2)}
</pre>
</div>
<div className="flex gap-2">
<button
onClick={onApprove}
className="px-4 py-2 bg-green-600 text-white rounded-lg"
>
✓ Approve
</button>
<button
onClick={onDeny}
className="px-4 py-2 bg-red-600 text-white rounded-lg"
>
✗ Deny
</button>
</div>
</div>
);
}Wire it up from your message renderer. Note the id you pass is the approval id (part.approval.id), not the tool call id:
{part.type === "tool-call" &&
part.state === "approval-requested" &&
part.approval && (
<ApprovalPrompt
part={part}
onApprove={() =>
addToolApprovalResponse({ id: part.approval!.id, approved: true })
}
onDeny={() =>
addToolApprovalResponse({ id: part.approval!.id, approved: false })
}
/>
)}Client tools can also require approval:
import { toolDefinition } from "@tanstack/ai";
import { z } from "zod";
import { useChat, fetchServerSentEvents } from "@tanstack/ai-react";
import { clientTools } from "@tanstack/ai-client";
// tools/definitions.ts
const deleteLocalDataDef = toolDefinition({
name: "delete_local_data",
description: "Delete data from local storage",
inputSchema: z.object({
key: z.string(),
}),
outputSchema: z.object({
deleted: z.boolean(),
}),
needsApproval: true, // Requires approval even on client
});
// Client: Create implementation
const deleteLocalData = deleteLocalDataDef.client((input) => {
// This will only execute after approval
localStorage.removeItem(input.key);
return { deleted: true };
});
const { messages, addToolApprovalResponse } = useChat({
connection: fetchServerSentEvents("/api/chat"),
// Wrap client tools in `clientTools(...)` so literal tool-name inference is
// preserved — this is what lets `part.name === "delete_local_data"` narrow
// `part.input` / `part.output` to this tool's types.
tools: clientTools(deleteLocalData), // Automatic execution after approval
});// Define tool with approval requirement
const purchaseItemDef = toolDefinition({
name: "purchase_item",
description: "Purchase an item from the store",
inputSchema: z.object({
itemId: z.string(),
quantity: z.number(),
price: z.number(),
}),
outputSchema: z.object({
orderId: z.string(),
total: z.number(),
}),
needsApproval: true,
});
// Create server implementation
const purchaseItem = purchaseItemDef.server(async ({ itemId, quantity, price }) => {
const order = await createOrder({ itemId, quantity, price });
return { orderId: order.id, total: price * quantity };
});The user will see an approval prompt showing the item, quantity, and price before the purchase is made. The tool will only execute after the user approves.
Use approval for sensitive operations - Sending emails, making payments, deleting data
Show clear information - Display what the tool will do before approval
Provide context - Show tool arguments in a readable format
Handle denial gracefully - Don't break the conversation if a tool is denied
Timeout handling - Consider timeouts for approval requests