Framework adapters
Connect customer answers in the native Pushary app to your framework’s questions and protected tool calls.
This page is for teams building an AI agent who want to pause it and ask a real human, delivered to that person's phone. It builds on Embed human approval and runs on the Partner plan.
Start here: ask or gate
There are two ways to involve a human, and picking the wrong one is the mistake that costs people a weekend.
Ask gives the model a tool it can call. Good for "check with someone before you guess". The catch is that it is the model's choice, and a model in a hurry can decide not to call it.
Gate hooks the framework's own approval mechanism. The runtime asks the human before the tool function runs. There is no path around it, because the model never gets a say. Use this for anything that spends money, deletes data, or leaves your system.
| Ask | Gate | |
|---|---|---|
| Who decides to involve a human | The model | Your code |
| Can the model skip it | Yes | No |
| Right for | "Is this the right address?" | "Refund $4,000" |
| What you write | Add a tool | Add one field to a tool |
Most products want both: a general ask_human tool, plus a gate on the two or
three tools that can actually hurt.
What you are not building
Both paths are a few lines because the hard parts already exist. If you wire a human into an agent yourself, this is the list you end up owning:
| The part | What Pushary already does |
|---|---|
| Reaching the person | Native iOS and Android push, PWA push, and Slack, from one call |
| Them answering | Confirm, choice, or text in the native app; supported confirm notifications offer quick actions. The web decision page remains available. |
| Waiting | Pushary persists the decision. Your framework and application persist the run and coordinate resumption through polling or a signed callback. |
| Asking twice | Reuse an idempotency key for the same saved action. Your application must also prevent duplicate business execution. |
| Nobody answering | Fail-closed by default: silence is a no, never a yes |
| Proving it later | Every ask and answer in the audit log |
The honest version of the pitch: the ask is easy, and everything after it is not.
The contract underneath
Whatever framework you use, and whichever mode you pick, it is the same two calls:
- Connect a phone once.
enroll(externalId)returns a link. Show it to your end-user. One tap opens the Pushary app, which is what puts Approve and Deny on their lock screen. No account and no charge to them. Show the link as a QR they can re-scan rather than a tap-through: iOS does not re-deliver a universal link after an App Store install, so someone installing the app for the first time finishes by scanning it again from inside the app. - Ask a human.
ask({ question, externalId })creates a decision, delivers it, and blocks until they answer. You get a fail-closedapprovedflag back.
externalId is your own stable id for the end-user (a user id, email hash, tenant
key). Use the same value for enroll and every ask for that person.
Any framework: @pushary/server
The core SDK is the floor under every adapter. If your framework can call a function, this works.
npm i @pushary/serverimport { createPusharyServer } from '@pushary/server'
const pushary = createPusharyServer({ apiKey: process.env.PUSHARY_API_KEY! })
// once per end-user: show the link, they tap to connect their phone
const { universalLink } = await pushary.enroll(user.id)
// whenever your agent needs a human:
const { approved } = await pushary.decisions.ask({
externalId: user.id,
question: 'Issue a $50 refund?',
type: 'confirm', // confirm | select | input
})
if (approved) await issueRefund()Any framework, in Python: pushary
The Python SDK carries the same two calls with snake_case arguments and dict returns.
pip install pusharyimport os
from pushary import PusharyServer
px = PusharyServer(api_key=os.environ["PUSHARY_API_KEY"])
# once per end-user: show the link, they tap to connect their phone
link = px.enroll("user_123")["universalLink"]
# whenever your agent needs a human:
d = px.decisions.ask(
question="Issue a $50 refund?",
external_id="user_123",
type="confirm", # confirm | select | input
)
if d["approved"]:
issue_refund()Vercel AI SDK
Install @pushary/ai-sdk alongside the compatible ai and zod peers.
Use the public package guide
for complete examples and version requirements.
createPusharyTools adds optional questions. A prompt asking the model to seek
permission does not protect another tool. Use the framework's enforced approval
mechanism for protected execution, and policy: false when a person must answer.
Your server binds externalId to the authenticated customer, then presents their
enrollment link.
Published @pushary/ai-sdk@0.3.0 requires server SDK 2.1. For late answers, use the
delayed-review recipe
with saved AI SDK messages and application SQLite state. The recipe requires AI SDK
7 and Node.js 22.13 or later; follow its tested-version guidance. It preserves
generated output and refuses uncertain re-execution. The application owns
persistence and recovery.
Eve
npm i @pushary/eveAsk. Eve discovers tools by file. Drop in two one-line files:
// agent/tools/ask-human.ts
import { pusharyAskHuman } from '@pushary/eve'
export default pusharyAskHuman()// agent/tools/connect-phone.ts
import { pusharyConnectPhone } from '@pushary/eve'
export default pusharyConnectPhone()By default each tool asks the session principal, so run user-scoped auth and
each end-user is their own principal. To bind a fixed end-user, pass one:
pusharyAskHuman({ externalId: user.id }).
Gate. Eve has a per-tool approval field it evaluates before execute. Pass
pusharyApproval() and the tool cannot run without a yes:
// agent/tools/issue-refund.ts
import { defineTool } from 'eve/tools'
import { z } from 'zod'
import { pusharyApproval } from '@pushary/eve'
export default defineTool({
description: 'Refund an order',
inputSchema: z.object({ amount: z.number(), customer: z.string() }),
approval: pusharyApproval({ externalId: (ctx) => ctx.toolInput?.customer }),
execute: async ({ amount }) => refund(amount),
})It sits alongside Eve's built-in always(), never() and once(). Those decide
statically; this one asks a person and waits.
The channel. pusharyChannel() is the third option, and the one to reach for in
a real product. It puts everything Eve already pauses on onto a phone: any tool
gated with approval, plus the built-in ask_question. Eve parks the turn durably
while it waits, so an approval can sit for hours at zero idle compute.
// agent/channels/pushary.ts
import { pusharyChannel } from '@pushary/eve'
export default pusharyChannel()PUSHARY_API_KEY=pk_...sk_...
PUSHARY_WEBHOOK_SECRET=whsec_... # decisions.getWebhookSecret()
PUSHARY_CALLBACK_ORIGIN=https://your-agent.vercel.appThe channel also exposes POST /pushary/message, /pushary/stop and
/pushary/reset, so the rest of the session is reachable from the phone too.
Requires eve@0.31 or newer. Eve is in public preview and its channel API has
already moved once, so pin your eve version.
LangGraph and LangChain
Install @pushary/langgraph in TypeScript or pushary-langgraph in Python.
The public package guide
covers customer connection, blocking questions and native graph interrupts.
Bind the customer from your authenticated application. A blocking question is appropriate for a short wait. For delayed answers, use a persistent LangGraph checkpointer, save the exact decision-to-thread mapping, and resume only after reading and validating the authoritative decision. Confirm approval must guard the business node; select and text answers do not grant execution permission.
Published @pushary/langgraph@0.4.0 and pushary-langgraph==0.4.0 require shared
SDK 2.1 and bind reviews to the recipient and action. Follow the
native interrupt guide
and its restart test.
Avoid combining examples from different package versions. An application worker
still coordinates callbacks, duplicate resumes, output storage and uncertain
business effects.
CrewAI
Install pushary-crewai and give the agent a ready-made tool instead of the console
human_input=True prompt.
pip install pushary-crewaifrom crewai import Agent
from pushary_crewai import connect, make_ask_human_tool
connect("user_123") # show the link once
agent = Agent(role="Ops", goal="Ship safely", tools=[make_ask_human_tool("user_123")])The external_id is bound to the tool, so a prompt-injected agent cannot ask the
wrong person. The tool blocks fail-closed until they answer.
OpenAI Agents SDK
Install @pushary/openai-agents in TypeScript or pushary-openai-agents in Python.
Follow the public package guide
for exact peer versions and complete examples.
A pusharyTool / pushary_tool lets the model ask a question. Native
needsApproval / needs_approval protects a business tool before execution.
Your server selects the customer, shows their enrollment link, and resolves only
the interruptions belonging to that customer's saved run. Use policy: false
when a real person must answer.
Published @pushary/openai-agents@0.4.0 and pushary-openai-agents==0.4.0 require
shared SDK 2.1. The
TypeScript delayed-answer recipe
persists native RunState, immutable call bindings, claims and generated output
in application SQLite state. It requires Node.js 22.13 or later and exercises
answers after a process restart, refusing uncertain re-execution. Python callers
supply equivalent application coordination; the blocking Python resolver alone
does not provide durable scheduling.
Mastra
Use @pushary/mastra with an authenticated customer's ID. Begin with
customer enrollment:
show the returned link, wait for consent, and check reachability before creating
a review. An owner demonstration is separate from connecting a product customer.
For protected actions, set Mastra's native requireApproval: true on the tool.
A model-selectable ask tool alone does not protect another tool. Confirm yes/no
is execution permission; a choice or written answer remains data, including the
word yes.
Published @pushary/mastra@0.4.0 requires server SDK 2.1, Mastra 1.59 or later
below version 2, and Node.js 22.13 or later. The
public package guide covers these paths:
- Short waits:
resolvePusharyApprovalstakes only the trusted suspended runs belonging to this customer and action. Setpolicy: falsewhen a real human must answer. Save every returned generation and inspect pending/error results. - Delayed answers:
openPusharyAgentReviewsaves the exact call and customer binding.resumePusharyAgentReviewreads the authoritative decision before resuming the saved Mastra run. Your application supplies persistent Mastra storage, an atomic review store, output persistence, and business idempotency.
The published restart and live-phone recipe needs no model key, moves no money, and polls without a public callback server. Its approval, denial, choice and text checks run in separate processes and verify duplicate handling. Follow the public guide for the simulation and live commands; simulated HTTP checks alone do not prove phone delivery.
Pushary records the answer. The application separately records continuation and the business result. If execution becomes uncertain, reconcile the saved run and business receipt before retrying; a timeout alone never makes a retry safe.
Pydantic AI
The new pushary-pydantic-ai package is awaiting release. Its source candidate
has been tested, but it does not yet have a public package installation or guide.
Use the Python SDK and
Partner REST integration while the native adapter is being
prepared. Do not assume an unpublished package is available from the registry.
Use Pydantic AI's native deferred tool requests for protected actions and preserve its message history before waiting. Your application binds each request to the authenticated customer, reads the authoritative Pushary answer and supplies the matching deferred result. The native app supports yes/no, choice and text; only an explicit confirm approval permits the protected action. Follow the recipe's persistence, duplicate handling and recovery requirements before using real business effects.
Hermes
pip install pushary into Hermes' environment, then register a tool in a plugin:
def ask_human(params, **kwargs):
d = px.decisions.ask(
question=params["question"],
external_id=params["external_id"],
type="confirm",
)
return "approved" if d["approved"] else f"not approved ({d['status']})"
def register(ctx):
ctx.register_tool(
name="ask_human",
toolset="approvals",
schema={"type": "object", "properties": {
"question": {"type": "string"},
"external_id": {"type": "string"},
}, "required": ["question", "external_id"]},
handler=ask_human,
description="Ask a person to approve before acting.",
)In gateway mode, pass each user's external_id from your own user model rather than
a session id.
OpenClaw
Gate risky tool calls with a before_tool_call hook:
import { definePluginEntry } from 'openclaw/plugin-sdk/plugin-entry'
export default definePluginEntry({
id: 'pushary-approvals',
name: 'Pushary approvals',
register(api) {
api.on('before_tool_call', async (event) => {
if (event.toolName !== 'deploy_service') return
const d = await pushary.decisions.ask({
question: `Allow ${event.toolName}?`,
externalId: 'user_123',
type: 'confirm',
})
if (!d.approved) return { block: true, blockReason: 'A human declined this action.' }
})
},
})Claude Agent SDK
The SDK's MCP support is the integration. One mcpServers entry, no install:
import { query } from '@anthropic-ai/claude-agent-sdk'
for await (const message of query({
prompt: 'Clean up stale feature flags. Ask before deleting anything.',
options: {
mcpServers: {
pushary: {
type: 'http',
url: 'https://pushary.com/api/mcp/mcp',
headers: { Authorization: `Bearer ${process.env.PUSHARY_API_KEY}` },
},
},
allowedTools: ['mcp__pushary__ask_user', 'mcp__pushary__wait_for_answer', 'mcp__pushary__send_notification'],
},
})) {
// ...
}These MCP tools reach the phones connected to your workspace. When the agent serves
your own end-users, call decisions.ask from @pushary/server inside a custom tool
with each user's externalId instead.
Durable workflows (Inngest, Temporal, Vercel Workflow)
For waits of minutes or hours that must not hold idle compute, install
@pushary/durable. It has no framework dependency of its own, so it drops into
whichever runner you use.
npm i @pushary/durableimport { createApproval, resolveApproval, deterministicKey } from '@pushary/durable'
// inside a step: open the decision and park the run
const { correlationId } = await createApproval(
{ apiKey: process.env.PUSHARY_API_KEY! },
{
externalId: user.id,
question: 'Approve this transfer?',
callbackUrl: 'https://yourapp.com/webhooks/pushary',
idempotencyKey: deterministicKey([runId, 'approve-transfer']),
},
)
// store correlationId -> runId in your own state, then suspend/wait.
// in your callback route: verify + parse in one call
const approval = resolveApproval(rawBody, signature, process.env.PUSHARY_WEBHOOK_SECRET!)
if (approval?.approved) resumeRun(approval.correlationId)resolveApproval verifies the HMAC signature first (a spoofed request never resumes
your workflow), then returns { correlationId, answer, value, approved, context? }.
Look up your run by correlationId, or pass context: runId at create time and read
it straight off the callback. The README has copy-paste recipes for Inngest, Temporal,
and Vercel Workflow.
Your own framework: @pushary/server/adapters
Every adapter above is a thin binding over one shared kernel, and that kernel is public. If you run an in-house harness, or a framework we have not shipped for, this is the same surface our own adapters are built on.
import { createAdapterKernel, renderApprovalQuestion } from '@pushary/server/adapters'
const kernel = createAdapterKernel('the Acme helpers')
// blocking ask, idempotency and fail-closed already handled
export const askHuman = kernel.askExternalUser
// durable create for a framework that parks its own run
export const openDecision = kernel.createDurableDecision
// an enforced gate: build it once, call it per tool call
const gate = kernel.createGate({ apiKey: process.env.PUSHARY_API_KEY! })
export const approve = async (toolName: string, callId: string, input: unknown) => {
const decision = await gate({
toolName,
callId,
sessionId: currentRunId,
question: renderApprovalQuestion(toolName, input),
externalId: kernel.requireExternalId(currentUserId),
// optional: lets a rule decide on the arguments, not only the action name
input,
})
return decision // { approved: true } | { approved: false, reason }
}The gate asks your policy before it asks a person. A rule that names the action
resolves it with nobody paged: allow returns approved and opens no decision, deny
returns a reason the model reads, and anything no rule names still asks. A site with
no rules behaves exactly as before, because a rule has to name the action and * is
never selected. Pass policy: false to restore the always-ask gate.
The label you pass to createAdapterKernel is what appears in the error when a key
or an end-user is missing, so it points at your helpers rather than ours.
One protected action: protect()
protect() uses authorization and execution-permit endpoints. Configure it with
a separately authorized server credential; the onboarding runtime key cannot call
those endpoints. Keep that credential outside the model and customer browser.
createGate answers a yes/no. protect() is that plus running the thing, so the
authorize, escalate and execute steps stop being three pieces of plumbing in your
business code.
const protect = kernel.protect({ apiKey: process.env.PUSHARY_API_KEY! })
const outcome = await protect({
action: 'refund.create',
target: 'order_4471',
externalId: customer.id,
facts: { amount: 4800, currency: 'EUR' }, // what a rule may decide on
callId: attemptId,
runId: sessionId,
run: () => stripe.refunds.create({ charge, amount: 4800 }),
})
outcome.ok ? outcome.result : outcome.reasonA rule that names refund.create resolves it with nobody paged. Anything no rule
names asks a person. run is called only after the action is authorized, and an
error it throws is not caught: reporting it as ok: false would be
indistinguishable from a refusal.
protect() is at most once. The decision is idempotent — a replay of the same
runId + callId + action lands on the same approval instead of asking twice — and
the approval is then spent against a durable permit before run is called. A retry, a
concurrent worker and a resumed run all reach the same permit and exactly one of them
proceeds; the rest come back ok: false with a reason worded to stop the model rather
than invite another attempt.
The permit is bound to the exact subject that was authorized, so changing the amount
between the approval and the execution leaves you with no permit for the action you
now want to run. protect() then records succeeded or failed against it. A process
that dies mid-action leaves the permit unresolved, which is visible and reconcilable,
rather than an action that ran twice, which is not.
See Run it once, and prove it ran for the two calls underneath, and the refusals you can branch on.
Python is the same two-step surface: protect = kernel.create_protect(), then
protect(action, run, external_id=..., call_id=..., run_id=...). Building it once is
what makes a missing key raise where the protector is defined rather than on the
first action.
A runnable end-to-end version, against the sandbox and with no phone involved, is
in examples/protected-action.ts in @pushary/server.
Also on the subpath: describeAnswer turns an outcome into an instruction the model
cannot misread, resolvePusharyCallback verifies and parses a webhook in one call
(SIGNATURE_HEADER is the header to read it from), and idempotencyKeyFor preserves an
explicit idempotencyKey or generates a fresh key for an independent ask. isApproved is the fail-closed
check itself, if you only want the rule.
Python has the same surface as pushary.adapters:
from pushary.adapters import AdapterKernel, render_approval_question
kernel = AdapterKernel("the Acme helpers")
ask_human = kernel.ask_human
gate = kernel.create_gate()create_gate(policy=False) is the same escape hatch. Neither language evaluates a
rule locally; the verdict is the server's, so the two cannot disagree about what a
policy means.
Both give you the same guarantees the shipped adapters have: explicit operation keys deduplicate retries, independent blocking calls get fresh identities, durable creates require a key, and unanswered decisions are treated as a no.
No code at all: MCP
If your agent speaks the Model Context Protocol, point it at the Pushary MCP server with your API key and skip the SDK entirely. See Connect any agent.
On the Partner plan, an appropriately authorized MCP connection exposes end-user decision tools. Use a recipient-bound credential issued by your trusted backend; the onboarding runtime key’s REST access does not grant these MCP capabilities. An MCP agent can then use:
enroll_end_user({ externalId })mints the keyless one-tap connect link for one of your users.create_decision({ question, externalId, ... })writes a durable decision and delivers it to that user's phone.get_decision({ decisionId, waitSeconds })is the durable wait: loop it (waitSecondscaps at 20) until the status leavespending.cancel_decision({ decisionId })retracts a stale request.
Same fail-closed rule as the SDKs: only an answered, approving decision is a yes. The
API key holder controls which externalId a decision targets, so treat the key like
the production credential it is.
What to reach for
- The model should decide when to ask: the ask tool.
- This must not happen without a yes: the gate.
- One synchronous approval in a request that finishes in under a minute:
ask(). - Long waits or crash-safe resume:
create()+callbackUrlunder a durable runner. - Zero code: MCP.
Every path writes to the same durable decision ledger, so an answer is never lost even if your process restarts mid-wait, and every one of them shows up in the same audit log.
Each framework also has a condensed integration page with the end-user story: the human-in-the-loop hub links to all of them.