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:
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:
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: "..." };
});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);
}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>
);
}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>
);
}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 })
}
/>
)}{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
});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 };
});// 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.