# Human-in-the-loop approval with the Vercel AI SDK

> Wire AI SDK 7 tool approvals to an approval card: toolApproval on the server, approval-requested parts in useChat, addToolApprovalResponse on approve or reject.

Published 2026-11-04. Facts checked 2026-11-04. Source: https://pixilui.com/guides/human-approval-vercel-ai-sdk

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](https://ai-sdk.dev/docs/agents/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](https://ai-sdk.dev/docs/ai-sdk-ui/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-requested` with `approval.isAutomatic` set. Show those as a quiet row, never as a card with buttons.
- **The reason is separate from the note.** `approval.requestReason` is why the server asked. The note the person types on reject goes back as `reason` and 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.

```ts
// lib/refund-agent.ts
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>>;
```

```ts
// app/api/chat/route.ts
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.

```tsx
// components/refund-chat.tsx
"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 `addToolApprovalResponse` is 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](/docs/migrate-from-ai-elements) 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](/guides/human-in-the-loop-approval-for-ai-agents): what a good approval step shows, framework aside.
- [Human approval with LangGraph](/guides/human-approval-langgraph): the same card on an `interrupt()`.
- [Human approval with the OpenAI Agents SDK](/guides/human-approval-openai-agents-sdk): the same card on `needsApproval` and `RunState`.
- [Tool call UI with the Vercel AI SDK](/guides/tool-call-ui-vercel-ai-sdk): the row for calls that do not need a person.

## Sources

- [AI SDK: Tool Approvals](https://ai-sdk.dev/docs/agents/tool-approvals), read 4 November 2026
- [AI SDK UI: Chatbot Tool Usage](https://ai-sdk.dev/docs/ai-sdk-ui/chatbot-tool-usage), read 4 November 2026
- [AI SDK: WorkflowAgent](https://ai-sdk.dev/docs/agents/workflow-agent), read 4 November 2026
- [AI Elements](https://elements.ai-sdk.dev), read 4 November 2026
