How to add human-in-the-loop to a Vercel AI SDK agent
Add a human approval step to a Vercel AI SDK agent with @pushary/ai-sdk: an askHuman tool that reaches a person on their phone and blocks until they decide.
To add human-in-the-loop to a Vercel AI SDK agent, install @pushary/ai-sdk and give your agent an askHuman tool. When the model reaches a step a person should sign off on, it calls the tool, the question lands on that person's phone, and the tool's execute blocks until they approve, decline, or type an answer. The result flows back into the agent loop as a normal tool result, and silence resolves to not approved.
Key takeaways
createPusharyTools({ externalId })returns anaskHumantool you spread into thetoolsofgenerateTextorstreamText. The tool loop itself does the pausing.- The person answers on their phone, from the lock screen. The
externalIdis your own id for the user, connected once with a keyless one-tap enroll link. - The decision is fail-closed: a decline, an expiry, or no answer is reported to the model as not approved, so the guarded action does not run on silence.
Why the tool loop is the right seam
The AI SDK's agent loop already knows how to pause. When the model emits a tool call, generateText waits for that tool's execute to resolve before it continues. That means a human approval does not need special orchestration: make the human a tool, and the loop holds the agent for you.
The part the SDK leaves to you is the human side. needsApproval can mark a tool call as requiring approval, and then the pending call is handed back to your code. Nothing is delivered, nobody is notified, and nothing records what was decided. If the approver is away from the keyboard, or the approver is one of your users rather than you, that gap is the entire problem.
The whole integration
npm i @pushary/ai-sdkimport { generateText, stepCountIs } from "ai"
import { openai } from "@ai-sdk/openai"
import { createPusharyTools, enroll } from "@pushary/ai-sdk"
// Connect the end-user's phone once. Keyless, one tap, no app to install.
const { universalLink } = await enroll({ apiKey: process.env.PUSHARY_API_KEY! }, user.id)
// Show universalLink to the user once (a button, a QR code, an email); one tap connects their phone.
const { text } = await generateText({
model: openai("gpt-4o"),
tools: createPusharyTools({
apiKey: process.env.PUSHARY_API_KEY!,
externalId: user.id,
}),
stopWhen: stepCountIs(10),
prompt: "Issue the refund only if a human approves it.",
})Two calls carry the whole feature. enroll(externalId) connects a phone to your own user id, once. createPusharyTools({ externalId }) gives the model an askHuman tool scoped to that person. Everything else is the AI SDK you already run.
When the model calls askHuman, Pushary creates a decision in a durable ledger, sends a push notification to the person's phone (native app push first, PWA push notifications otherwise, Slack if routed there), and polls the decision until it resolves. The tool result tells the model what the human said, including the option they chose or the text they typed for select and input questions.
Fail-closed, spelled out
An approval step that assumes yes on timeout is an approval step in name only. The askHuman tool reports anything short of an explicit approval as not approved: a decline, an expired window, an unanswered question. The model sees the refusal and can explain, retry later, or take a safer path. Because the pending decision lives in a ledger rather than in process memory, a restart or a redeploy does not lose it.
Your users, not just you
The externalId in the example is doing quiet but important work. It is your id for your user, from your own user model. That makes this more than a notify-the-developer setup: the agent you ship can ask each of your users about their own actions. The refund question goes to the customer whose money moves. The delete confirmation goes to the workspace owner. Each person connected their phone once, with one tap, and never saw a Pushary login.
Notifications too, same two calls
The same integration carries plain notifications: task finished, error hit, attention needed. Those are one-way sends and they use the same delivery path and the same enrollment. The decision tool adds the blocking wait and the ledger on top, and you choose per step which one you need.
Where to go next
The Vercel AI SDK integration page is the condensed version of this post. The human-in-the-loop hub covers the pattern across LangGraph, CrewAI, Mastra, the OpenAI Agents SDK, Eve, Hermes, and MCP. The framework adapters guide has the two-call quickstart for every framework.
Frequently asked questions
How do I add human-in-the-loop to a Vercel AI SDK agent?
Install @pushary/ai-sdk and call createPusharyTools({ externalId }). It returns an askHuman tool you spread into the tools object of generateText or streamText. When the model calls it, the question is delivered to that person's phone as a push notification and the tool blocks until they approve, deny, or type an answer. No reply means not approved.
How is this different from the AI SDK's needsApproval option?
needsApproval pauses the tool call and hands the decision back to your code. It does not deliver anything to a person or wait for them. Pushary supplies that half: the delivery to a phone, the blocking wait, the fail-closed timeout, and the record. The two compose, with needsApproval deciding when to stop and Pushary reaching the human.
Do my end-users need a Pushary account?
No. You reference each user with your own externalId, and they connect their phone once through a keyless one-tap link. There is no account, no app requirement, and no API key on their side.