# Vercel AI SDK

Source: https://pushary.com/docs/agents/build/vercel-ai-sdk
Last updated: 2026-10-04

> Vercel AI SDK human in the loop with toolApproval. Your user taps Approve or Deny on their phone before a risky tool runs, and no answer means no.

`@pushary/ai-sdk` adds human in the loop approval to Vercel AI SDK agents. Before a tool runs, the AI SDK asks an approval function whether it may. Pushary is that function: it sends the tool call to the right user's phone and returns their answer. The tool runs only if they tap Approve. It works with `generateText`, `streamText` and `ToolLoopAgent` in AI SDK 7.

```bash
npm i @pushary/ai-sdk ai zod
export PUSHARY_API_KEY=pk_xxx.xxx
```

Get the key at [Partner onboarding](https://pushary.com/onboarding/partner). The approval gate needs AI SDK 7 and Node.js 22 or later.

## Set up AI SDK human in the loop [#set-up-ai-sdk-human-in-the-loop]

<Steps>
  <Step>
    ### Connect a user once [#connect-a-user-once]

    ```ts
    import { enroll } from '@pushary/ai-sdk'

    const { universalLink } = await enroll({ apiKey: process.env.PUSHARY_API_KEY! }, user.id)
    // Show universalLink to this user. They open it on their phone once.
    ```

    `user.id` is your own id for the person who answers. Your users install the free Pushary app. They never sign up or pay.
  </Step>

  <Step>
    ### Gate the risky tool with toolApproval [#gate-the-risky-tool-with-toolapproval]

    Pass `pusharyApproval()` as `toolApproval` and list the tools that need a yes. Every other tool runs as normal.

    ```ts
    import { generateText, stepCountIs, tool } from 'ai'
    import { z } from 'zod'
    import { pusharyApproval } from '@pushary/ai-sdk'

    const issueRefund = tool({
      description: 'Refund an order',
      inputSchema: z.object({ orderId: z.string(), amount: z.number() }),
      execute: async ({ orderId, amount }) => refundOrder(orderId, amount),
    })

    const lookupOrder = tool({
      description: 'Look up an order',
      inputSchema: z.object({ orderId: z.string() }),
      execute: async ({ orderId }) => findOrder(orderId),
    })

    const { text } = await generateText({
      model: 'openai/gpt-4o',
      tools: { issueRefund, lookupOrder },
      toolApproval: pusharyApproval({ externalId: user.id, tools: ['issueRefund'] }),
      stopWhen: stepCountIs(5),
      prompt: 'Refund order 1234 in full.',
    })
    ```

    `refundOrder` and `findOrder` are your own functions.
  </Step>

  <Step>
    ### Run it and answer on the phone [#run-it-and-answer-on-the-phone]

    When the model calls `issueRefund`, the AI SDK stops before `execute`. Your user's phone buzzes with the tool name and its input. They tap Approve and the refund runs. `lookupOrder` never asks.
  </Step>
</Steps>

The model cannot skip this step. The AI SDK asks the approval function before the tool runs, so the model never gets a say.

## What happens when nobody answers [#what-happens-when-nobody-answers]

The tool does not run. Each approval waits up to 55 seconds. A deny, an expired question or no answer in that time all come back as denied, and the AI SDK tells the model the call was not approved:

| Outcome                | Reason the model reads                                                           |
| ---------------------- | -------------------------------------------------------------------------------- |
| Your user taps Deny    | `The approver denied this action.`                                               |
| Nobody answers in time | `No answer from the approver, so this was denied. Do not retry the same action.` |

## Let the model ask a question [#let-the-model-ask-a-question]

Sometimes the agent needs an answer, not a permission: which order, which address. `createPusharyTools` gives the model an `askHuman` tool it can call.

```ts
import { generateText, stepCountIs } from 'ai'
import { createPusharyTools } from '@pushary/ai-sdk'

const { text } = await generateText({
  model: 'openai/gpt-4o',
  tools: createPusharyTools({ apiKey: process.env.PUSHARY_API_KEY!, externalId: user.id }),
  stopWhen: stepCountIs(10),
  prompt: 'Ask the customer which order they need help with.',
})
```

The model chooses when to call `askHuman`, so use it for questions. Use `toolApproval` for anything that spends money, deletes data, or leaves your system. To add your own tools, spread them in: `tools: { ...createPusharyTools(config), ...myTools }`.

## Production notes [#production-notes]

### Make sure a person always decides [#make-sure-a-person-always-decides]

`pusharyApproval()` asks your site's policy first. A policy rule that names the tool can allow or deny it without paging anyone. Set `policy: false` when a person must always answer:

```ts
toolApproval: pusharyApproval({ externalId: user.id, tools: ['issueRefund'], policy: false })
```

### Gate one tool by name [#gate-one-tool-by-name]

The per-tool form of `toolApproval` takes `pusharyToolApproval`:

```ts
import { pusharyToolApproval } from '@pushary/ai-sdk'

toolApproval: {
  issueRefund: pusharyToolApproval({ toolName: 'issueRefund', externalId: user.id }),
}
```

Drop `tools` from `pusharyApproval()` to gate every call.

### Retries and idempotency [#retries-and-idempotency]

The same tool call never asks twice. The question is keyed on the tool call id, the recipient and the full input, so a provider-level retry reuses the first question. A changed recipient or changed input asks again. If you replay a run under tool call ids you mint yourself, pass a stable `sessionId`.

### Pick the right person [#pick-the-right-person]

Take `externalId` from your signed-in user, never from the model's tool input. The model writes the tool input, so a prompt injection could send the approval to someone else.

### Answers that come later than 55 seconds [#answers-that-come-later-than-55-seconds]

Set `timeoutMs` to change the wait. A later answer does not restart a finished request. For answers that may take minutes or hours, save the AI SDK's pending approval and messages with the decision id, then resume only after you read the matching answer. The [delayed-review recipe](https://github.com/Pushary/pushary-ai-sdk/blob/main/examples/DELAYED-REVIEWS.md) does this with SQLite and needs Node.js 22.13 or later. To wake your worker when an answer lands, use a [signed webhook](/docs/agents/embed#webhooks).

### Versions [#versions]

`toolApproval` needs `ai@7`. It is not in `ai@5.0.0` or `ai@6.0.0`. The `askHuman` tool works from `ai@5`. `@pushary/ai-sdk` 0.3 requires `@pushary/server` 2.1.

Source and more examples: [@pushary/ai-sdk on GitHub](https://github.com/Pushary/pushary-ai-sdk). AI SDK reference: [Tool calling and tool approval](https://ai-sdk.dev/docs/ai-sdk-core/tools-and-tool-calling).

## Next steps [#next-steps]

<Cards>
  <Card title="Try a simulated decision" description="Approve or deny a refund in your browser before you connect a phone." href="/try" />

  <Card title="See the AI SDK approval workflow" description="The whole flow for Vercel AI SDK agents, from connect to resume." href="/human-in-the-loop-vercel-ai-sdk" />

  <Card title="Test your integration" description="Use a sandbox key to check approve, deny and no answer without a phone." href="/docs/agents/embed-testing" />
</Cards>