Vercel AI SDK
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.
npm i @pushary/ai-sdk ai zod
export PUSHARY_API_KEY=pk_xxx.xxxGet the key at Partner onboarding. The approval gate needs AI SDK 7 and Node.js 22 or later.
Set up AI SDK human in the loop
Connect a user once
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.
Gate the risky tool with toolApproval
Pass pusharyApproval() as toolApproval and list the tools that need a yes. Every other tool runs as normal.
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.
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.
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
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
Sometimes the agent needs an answer, not a permission: which order, which address. createPusharyTools gives the model an askHuman tool it can call.
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
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:
toolApproval: pusharyApproval({ externalId: user.id, tools: ['issueRefund'], policy: false })Gate one tool by name
The per-tool form of toolApproval takes pusharyToolApproval:
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
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
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
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 does this with SQLite and needs Node.js 22.13 or later. To wake your worker when an answer lands, use a signed webhook.
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. AI SDK reference: Tool calling and tool approval.
Next steps
Try a simulated decision
Approve or deny a refund in your browser before you connect a phone.
See the AI SDK approval workflow
The whole flow for Vercel AI SDK agents, from connect to resume.
Test your integration
Use a sandbox key to check approve, deny and no answer without a phone.