@pushary/ai-sdk
v0.2.0
Published
Vercel AI SDK tools for Pushary: pause your agent until a real human approves on their phone (human-in-the-loop).
Maintainers
Readme
@pushary/ai-sdk
Full walkthrough: Human-in-the-loop for the Vercel AI SDK. Reaching your own end-users on their phones is the Pushary Partner plan.
Human-in-the-loop for the Vercel AI SDK. Give your agent one tool that pauses until a real human approves on their phone, and answers from the lock screen.
Two calls is the whole integration:
enroll(externalId)once per end-user. Show them the link it returns. One tap connects their phone.- Add
createPusharyTools({ externalId })to your agent. Now it can ask that person and block on the answer.
No UI to build, no polling to write, no webhooks required. Requires the Pushary Partner plan.
Install
npm i @pushary/ai-sdk ai zodSet PUSHARY_API_KEY (get it in your dashboard).
Connect an end-user's phone (once)
import { enroll } from '@pushary/ai-sdk'
const { universalLink } = await enroll({ apiKey: process.env.PUSHARY_API_KEY! }, user.id)
// Show universalLink to the user as a button or QR. One tap turns on approvals.
// Cache the fact that they enrolled, not the link itself (it is single-use).Give your agent a human to ask
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, // the enrolled person who answers
}),
stopWhen: stepCountIs(10),
prompt: 'Issue the refund only if a human approves it.',
})The agent gets an askHuman tool. When it calls it, the person gets a push notification and approves, declines, picks an option, or types an answer from their phone. The tool blocks until they reply, then hands the model an unambiguous result.
Behavior that matters
- Fail-closed. A declined, expired, or unanswered
confirmis reported to the model as "not approved, do not proceed." Approval only happens on an explicit yes. - Serverless-safe. Each ask blocks up to 55 seconds by default (
timeoutMs). The decision stays answerable for its full lifetime, so a slow human still resolves it. For waits of minutes or hours, run under a durable workflow (Inngest, Temporal, Vercel Workflow) and use acallbackUrl. - No double-asks on retry. The idempotency key is derived deterministically, so a retried step reuses the same decision instead of paging the human twice.
Gating a tool the model cannot skip
createPusharyTools gives the model a tool it chooses to call. That is right for
"go ask someone about this", and wrong for "this must not happen without a yes",
because a model that does not want to be interrupted can decline to call it.
For an enforced gate, use pusharyApproval() in the AI SDK's own toolApproval.
The SDK evaluates it before the tool executes, so there is no path around it:
import { generateText } from 'ai'
import { pusharyApproval } from '@pushary/ai-sdk'
const { text } = await generateText({
model: 'openai/gpt-4o',
tools: { issueRefund, lookupOrder },
// only issueRefund asks a human; lookupOrder runs untouched
toolApproval: pusharyApproval({ externalId: user.id, tools: ['issueRefund'] }),
prompt: 'Refund order 1234.',
})Drop tools to gate every call. For the per-tool form:
toolApproval: {
issueRefund: pusharyToolApproval({ toolName: 'issueRefund', externalId: user.id }),
}Fail-closed: a denial, an expiry, or nobody answering all come back denied and the tool does not run. The decision is keyed on the tool call, so a provider-level retry resolves to the same decision instead of asking twice.
For a multi-tenant product, resolve the end-user per call:
toolApproval: pusharyApproval({ externalId: (call) => ownerOf(call.input) })toolApproval does not exist in ai@5, so the gate needs a newer ai. The ask tool
above works from ai@5 on.
API
createPusharyTools(config)
config: { apiKey, externalId, agentName?, timeoutMs?, baseUrl? }. Returns { askHuman }, a tool you pass to generateText / streamText. Merge it with your own tools:
tools: { ...createPusharyTools({ apiKey, externalId }), ...myOtherTools }enroll(config, externalId)
config: { apiKey, baseUrl? }. Returns { token, deepLink, universalLink, expiresInSeconds }.
Under the hood
This package is a thin binding over the shared adapter kernel in @pushary/server (@pushary/server/adapters), which every Pushary framework adapter is built on. Use @pushary/server directly for any framework, @pushary/server/adapters to write your own adapter, or the Pushary MCP server to wire agents up with no code at all. See the adapters guide.
MIT
Example
A runnable example is in examples/.
