# 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](../interrupts/overview). For deprecated API mapping,
see [Migrate to AG-UI interrupts](../interrupts/migration).

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 produces a denied/cancelled result
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 ignore
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 ignore
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 ignore
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](../interrupts/multiple).

## Enabling Approval

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

```typescript
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:

```typescript
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 ignore
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](#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](../interrupts/migration).

## Client Tools with Approval

Client tools can also require approval:

```typescript
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

```typescript
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** - Check `part.outcome` when a tool is denied.
- **Timeout handling** - Consider timeouts for approval requests

## Tool Result Outcomes

Cancelled and denied tool results keep the existing `state: 'error'` and wire
`output-error` behavior for compatibility, but `ToolResultPart.outcome` is set
to `'cancelled'` or `'denied'`. Ordinary tool execution failures do not set an
`outcome`. The outcome can be produced by the user decision or by middleware
policy.

When a result is persisted as a `ModelMessage`, the outcome is stored in
`metadata.tanstack.toolResultOutcome` and restored to the UI part. Consumers
should use this structured value instead of matching the fixed error text:

```tsx ignore
const denied = message.parts.some(
  (part) => part.type === 'tool-result' && part.outcome === 'denied',
)
```

The `@tanstack/ai-client` package exposes the same value on the
`tool-result` part. Use `part.outcome` to distinguish a denial or cancellation
from an ordinary tool execution failure.

The structured outcome is emitted by both the AG-UI interrupt resume path and
the deprecated `addToolApprovalResponse` path. Both paths keep the existing
error content.

## Next Steps

- [Server Tools](./server-tools) - Learn about server-side tool execution
- [Client Tools](./client-tools) - Learn about client-side tool execution
