Tools

Tool Approval Flow

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:

The current client API exposes approvals as bound AG-UI interrupts. For the complete server/client lifecycle, atomic batch controls, generic interrupts, and recovery, see Interrupts. For deprecated API mapping, see Migrate to AG-UI interrupts.

  1. awaiting-input — Tool call started, no arguments yet
  2. input-streaming — Arguments arriving incrementally
  3. input-complete — All arguments received
  4. approval-requested — Waiting for user approval (only if needsApproval: true)
  5. 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.

Approvals run ephemerally: the run resumes from the full client message history that the browser sends back, so a stateless route needs no server storage to rebuild the paused call.

When a tool requires approval, the typical flow is:

  1. Model calls the tool
  2. Tool execution is paused
  3. User is prompted to approve or deny
  4. Tool executes (if approved) or is cancelled (if denied)
  5. Conversation continues with the result

Resolve an approval interrupt

Without an approvalSchema, use the boolean shorthand. Approval uses the original tool input by default:

ts
const approval = interrupts.find(
  (interrupt) => interrupt.kind === 'tool-approval',
)

if (approval?.kind === 'tool-approval') {
  approval.resolveInterrupt(true)
}

An approvalSchema can define separate application payloads for approval and rejection:

ts
import { toolDefinition } from '@tanstack/ai'
import { z } from 'zod'

const transferDefinition = toolDefinition({
  name: 'transfer',
  description: 'Transfer funds',
  needsApproval: true,
  inputSchema: z.object({
    amount: z.number().positive(),
    recipient: z.string(),
  }),
  approvalSchema: {
    approve: z.object({ note: z.string() }),
    reject: z.object({ reason: z.string() }),
  },
})

Keep branch data under payload. Approved arguments can optionally be replaced in full with editedArgs; rejection never accepts edits:

ts
approval.resolveInterrupt(true, {
  editedArgs: { amount: 12, recipient: 'Ada' },
  payload: { note: 'Reviewed' },
})

approval.resolveInterrupt(false, {
  payload: { reason: 'Policy limit' },
})

Denial and cancellation are different. resolveInterrupt(false, ...) records a resolved rejection for the continuation. cancel() is payloadless and does not select the reject schema:

ts
approval.cancel()

A singleton submits after its valid resolution. Multiple items stage until all are valid, then submit atomically. Use root resolveInterrupts(...) for one synchronous batch transaction. See Multiple Interrupts.

Enabling Approval

Tools can be marked as requiring approval by setting needsApproval: true in the definition:

ts
import { toolDefinition } from '@tanstack/ai'
import { z } from 'zod'
import { emailService } from './email-service'

// 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: '...' }
})

Server-Side Approval

On the server, tools with needsApproval: true will pause execution and wait for approval:

ts
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)
}

Approval UI

Render pending approvals from the hook's interrupts array. Each tool-approval interrupt carries the tool name, the original arguments, and a resolveInterrupt you call with the user's decision. The array is already tool-agnostic, so one block handles every tool marked needsApproval: true — no per-tool part.name branch and no reading part.approval off a mixed union:

tsx
import { useChat, fetchServerSentEvents } from '@tanstack/ai-react'
import { sendEmail } from './tools'

function ChatComponent() {
  const { messages, sendMessage, interrupts, resuming } = useChat({
    connection: fetchServerSentEvents('/api/chat'),
    tools: [sendEmail],
  })

  return (
    <div>
      {/* ...render messages... */}
      {interrupts.map((interrupt) =>
        interrupt.kind === 'tool-approval' ? (
          <div key={interrupt.id} className="approval-prompt">
            <p>🔒 Approve {interrupt.toolName}?</p>
            <pre>{JSON.stringify(interrupt.originalArgs, null, 2)}</pre>
            <button
              disabled={!interrupt.canResolve || resuming}
              onClick={() => interrupt.resolveInterrupt(true)}
            >
              Approve
            </button>
            <button
              disabled={!interrupt.canResolve || resuming}
              onClick={() => interrupt.resolveInterrupt(false)}
            >
              Deny
            </button>
          </div>
        ) : null,
      )}
    </div>
  )
}

canResolve stays false until the interrupt is bound and ready; resuming is true while a resolution is in flight, so gate the buttons on both.

Migrating from addToolApprovalResponse

Older UIs read part.approval off tool-call parts and called addToolApprovalResponse({ id, approved }). That API is deprecated. Render from the interrupts array and call resolveInterrupt instead (see Approval UI above) — it is tool-agnostic by default, so the per-tool narrowing the part-based pattern needed goes away. For the full mapping, see Migrate to AG-UI interrupts.

Client Tools with Approval

Client tools can also require approval:

ts
import { toolDefinition } from '@tanstack/ai'
import { z } from 'zod'
import { useChat, fetchServerSentEvents } from '@tanstack/ai-react'

// 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, interrupts } = useChat({
  connection: fetchServerSentEvents('/api/chat'),
  // Pass client tools as a plain array — literal tool-name inference works
  // without a wrapper. The approval surfaces as a `tool-approval` interrupt you
  // resolve from `interrupts` (see Approval UI); the tool runs on approval.
  tools: [deleteLocalData], // Automatic execution after approval
})

Example: E-commerce Purchase

ts
import { toolDefinition } from '@tanstack/ai'
import { z } from 'zod'
import { createOrder } from './orders'

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

Best Practices

  • 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

Next Steps