Embed human approval in your product
Pause your own AI agent, ask your end-user to approve, and resume on their answer, with a typed SDK, verifiable webhooks, and an audit trail
The rest of these docs are about connecting your own agent to your own phone. This page is different: it is for teams building an AI product who want to pause their agent and ask their end-user to approve a consequential action, then resume on the answer. You keep your agent and your notification channel, and add one call. It runs on the Partner plan.
Building on a framework like the Vercel AI SDK or Eve? Framework adapters give you one tool plus one enroll call.
A human decision is asynchronous. It takes seconds, sometimes hours, so it is not
a normal function call. If your agent runs in a serverless function (Vercel,
Lambda, Cloud Run) you must use create-and-resume, not block-and-wait. Do not try
to await a human for 20 minutes inside a Lambda.
Two ways to wire it in
- Block and wait — call create with
wait: true; it holds the connection up to ~55s. Good for a long-lived agent (a desktop CLI agent, a worker you control). - Create and resume — create the decision, get an id back immediately, let your agent yield, and resume from a webhook or by polling. Use this for serverless.
Setup (once)
-
Get a Partner API key from your dashboard (
pk_xxx.sk_xxx). -
Connect the human on at least one channel. This is what makes them reachable, and you can offer more than one:
- Pushary app (recommended): call
enroll(externalId)and hand the user the returned link. It connects their phone (iPhone or Android) and approvals arrive on the lock screen. No account, no password, and nothing for them to pay — your Partner plan covers every end-user you enroll. See what your end-user sees for the two cases. - Browser web push: the same link lets them turn on browser notifications, or drop the browser SDK into your web app. Works on desktop and Android; on iPhone, web push needs the app installed.
- Slack (to your team): connect your Slack workspace in the dashboard to route approvals to a team channel in parallel.
You deliver
enroll(externalId)'suniversalLinkthrough your own channel (email, SMS, or an in-app button), or generate one from the dashboard.POST /api/v1/identifyis not a channel: it only attaches anexternalIdto a browser that already subscribed to Pushary web push. - Pushary app (recommended): call
Quick start (SDK)
npm install @pushary/serverimport { createPusharyServer, verifyWebhookSignature } from "@pushary/server"
const pushary = createPusharyServer({ apiKey: process.env.PUSHARY_API_KEY! })
// Create (async by default). Always pass an idempotencyKey.
const decision = await pushary.decisions.create({
externalId: user.id,
email: user.email, // optional: DMs this person in Slack, if connected
question: "Publish this generated video to your public profile?",
type: "confirm", // confirm | select | input
callbackUrl: "https://yourapp.com/webhooks/pushary",
idempotencyKey: `run-${runId}-step-3`,
expiresInSeconds: 3600, // how long the human has (default 1h, max 24h)
})
// Resume from the webhook, or poll durably.
const state = await pushary.decisions.get(decision.decisionId, { wait: 30 })
if (state.answered) resume(state.value)The same flow in Python (pip install pushary):
import os
from pushary import PusharyServer, verify_webhook_signature
pushary = PusharyServer(api_key=os.environ["PUSHARY_API_KEY"])
# Create (async by default). Always pass an idempotency_key.
decision = pushary.decisions.create(
"Publish this generated video to your public profile?",
type="confirm", # confirm | select | input
external_id=user.id,
email=user.email, # optional: DMs this person in Slack, if connected
callback_url="https://yourapp.com/webhooks/pushary",
idempotency_key=f"run-{run_id}-step-3",
expires_in_seconds=3600, # how long the human has (default 1h, max 24h)
)
# Resume from the webhook, or poll durably.
state = pushary.decisions.get(decision["decisionId"], wait=30)
if state["answered"]:
resume(state["value"])Or call the REST API directly
curl -X POST https://pushary.com/api/v1/server/decisions \
-H "Authorization: Bearer pk_xxx.sk_xxx" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: run-8f2a-step-3" \
-d '{
"externalId": "your-user-id-123",
"email": "approver@acme.com",
"question": "Publish this generated video to your public profile?",
"type": "confirm",
"callbackUrl": "https://yourapp.com/webhooks/pushary",
"expiresInSeconds": 3600
}'{ "decisionId": "b1c9...", "status": "pending", "answered": false, "pollUrl": "https://pushary.com/api/v1/server/decisions/b1c9...", "expiresInSeconds": 3600 }Make it a tool your agent can call
The decision call is just HTTP, so it becomes a tool in whatever your agent uses.
When the model calls the tool, your code runs the call. The model proposes the
question; your server attaches who to ask (externalId) and your API key. Never
let the model choose the recipient or hold the key.
Anthropic (Claude) / OpenAI (Codex)
Define a tool named e.g. ask_human_to_approve with a question (and optional
options) input. In your handler, inject the current user's externalId and call
decisions.create. For a serverless agent, return a pending result and resume
the run when the webhook fires; for a long-lived agent, call with wait: true and
return the answer inline.
Any MCP agent
If your agent speaks MCP, connect the Pushary MCP endpoint to get an ask-a-human
tool with no code, passing the end-user's externalId as an argument.
Resume: get the answer back
Webhook (fast path). On answer, Pushary POSTs your callbackUrl:
{
"correlationId": "b1c9...",
"answer": "yes",
"value": "yes",
"answeredAt": "2026-07-12T18:03:00Z",
"context": "run_42"
}Read answer (canonical; value is an alias) keyed by correlationId. context
echoes back whatever string you passed at create time, so a stateless handler can
carry its own run/step id through the callback without a correlationId-to-state
map. context is present only when you set it; older integrations that read just
answer keep working.
Verify it. X-Pushary-Signature is HMAC-SHA256 of the raw body with your own
webhook secret. Fetch it once (decisions.getWebhookSecret()), cache it, and check
with verifyWebhookSignature(rawBody, header, secret) — or parseDecisionCallback(rawBody)
after verifying. Rotate with decisions.rotateWebhookSecret().
Poll (fallback). GET /decisions/{id}?wait=30 long-polls up to N seconds and
reads durably, so it resolves even after the live window closes.
Always implement poll as well as webhook. Webhook for speed, poll so a dropped delivery never strands a run.
Answer from your own app (headless)
If you render the approval in your own authenticated UI instead of letting Pushary deliver it, relay the user's choice from your backend:
await pushary.decisions.answer(decisionId, "yes") // confirm: yes/no; select: the option; input: textIt resolves the decision exactly as the hosted page would (fires your webhook, unblocks a poll, writes the audit record). You authenticate the user; we trust your key.
What your end-user sees
enroll(externalId) returns a universalLink (https://pushary.com/e/<token>). What
happens when they open it depends on one thing: whether they already have the app.
They have the app. The link opens it straight on a consent screen showing your
name and logo. They tap once, the phone is registered against your site under that
externalId, and they are done.
They do not have the app. iOS hands the link to Safari, not to an app that is not installed yet, so they land on the connect page and tap through to the App Store. iOS does not re-deliver the link after an install, so when the app opens it has no token. They finish from inside the app instead: open it, tap "Invited by a company?" on the welcome screen, and scan the same QR. No account is created at any point.
Two consequences worth designing around:
- Send a QR they can still see, not only a tappable link. If you send the link by email or SMS and they open it on the same phone, they have nothing left to scan after installing. Showing the QR on a second screen (your web app, a desktop page, a printed sheet) or keeping the link openable again is what makes the second step work.
- The link is valid for 24 hours and is single-use. That is sized for the
scan → App Store → install → scan round trip. Expired or already-used links show
"This link has expired"; call
enroll(externalId)again for a fresh one, which is cheap and idempotent from your side.
Your end-user is never shown a price, a plan, or a purchase, and never signs in. If one of your users reports being asked to subscribe, they went through Sign in instead of "Invited by a company?" — that is the paid flow for people running their own agents, and it is a different product from the one you are embedding.
Requires the Pushary app 1.0.9 or later. Earlier versions had no in-app entry point, so an end-user who installed from the App Store could not finish enrolling.
Know if it will reach them
A decision addressed to an end-user returns a reachability signal so you can tell "no human was reachable" apart from "the human declined":
{ "decisionId": "b1c9...", "status": "pending", "reachable": true, "reachableChannels": 2, "deviceCount": 3 }reachable: false means that externalId has no enrolled device and no push
subscription, so the decision would expire unanswered. Enroll them first, or pass
requireReachable: true to have create / ask refuse with a 409 (code: "unreachable") instead of opening a decision that reaches nobody:
const r = await pushary.decisions.ask({
externalId: user.id,
question: "Approve this refund?",
requireReachable: true, // throws "unreachable" rather than asking a ghost
})Label what the decision is about
question is prose for the human. toolName and toolTarget are what the
decision is about, and they are what the audit trail groups on:
await pushary.decisions.ask({
externalId: user.id,
question: "Approve a $480 refund for order #4471?",
toolName: "refund.create", // the action
toolTarget: "order_4471", // what it acts on
})Both are optional and nothing behaves differently without them. With them, the action shows as its own column in your decisions view, narrows that view by prefix from the search box, lands in the CSV and JSON audit export, and risk-classifies the decision when it is answered.
The label is also what authorize matches a policy rule against, below. Use a
stable identifier rather than a sentence, because two spellings of one action are
two different actions.
These labels still produce no policy suggestions. Suggestion mining runs on coding-agent decisions only.
If you gate tools through an adapter, the gated tool's name is sent for you.
The rest of the action
Three more optional fields describe the same action in more detail. They ride
along on decisions.create, decisions.ask and authorize alike:
await pushary.decisions.ask({
externalId: user.id,
question: "Approve a $480 refund for order #4471?",
toolName: "refund.create",
toolTarget: "order_4471",
actor: "user:u_44", // whose authority the action claims
environment: "production", // which deployment it runs against
parameters: { amount: 480, currency: "USD" },
})actor is the principal the action runs on behalf of, which is not always the
person who approves it: externalId names who is asked, actor names who the
action is for. parameters takes scalars only (string, finite number, boolean),
at most 32 keys, and it is rejected as a whole with a 400 rather than partly
dropped, because a rule that reads a missing parameter can only ever decide more
permissively than one that reads it. String values are redacted for credentials
on the way in, since this lands in an append-only ledger.
parameters are read by authorization rules (below). A site with no rule over a
parameter still decides on the action and its target alone, so { amount: 5000 }
against a plain rule that allows refund.create returns allow until you write
a rule that reads the amount.
They appear in your decisions view under the action, and authorize records
them on the row it writes.
Let policy answer, and a person only when it cannot
decisions.ask() always asks a person. authorize() asks your policy first:
const a = await pushary.authorize({
toolName: "refund.create",
toolTarget: "order_4471",
externalId: user.id,
question: "Approve a $480 refund for order #4471?",
})
if (!a.approved) throw new Error(a.reason)
await stripe.refunds.create(...)a.approved is fail-closed: false for a denial, and false when policy deferred
to a person and nobody answered.
Rules that read the amount
A permission rule names an action. An authorization rule also reads its parameters, which is what makes a threshold expressible:
curl -X POST https://pushary.com/api/v1/server/authorization-rules \
-H "Authorization: Bearer $PUSHARY_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"toolPattern": "refund.create",
"effect": "require_approval",
"conditions": [{ "parameter": "amount", "operator": "gte", "value": 500 }]
}'Now authorize({ toolName: "refund.create", parameters: { amount: 480 } }) comes
back allow without paging anyone, and { amount: 900 } goes to a person.
effect is allow, deny, or require_approval. Operators are eq, neq,
gt, gte, lt, lte, in, not_in. Conditions in one rule are ANDed; an OR
is two rules.
GET the collection to list, PATCH /{id} to change one (send enabled: false
to take a rule out of the path without losing what it said, which matters because
past decisions name it), DELETE /{id} to remove it.
Four things worth knowing before you write one:
- Most restrictive wins. With several rules matching one call,
denybeatsrequire_approvalbeatsallow. There is no priority field, so the answer never depends on the order you created them in. - A missing parameter escalates, it does not skip the rule. If a rule decides
on
amountand a call does not send one, that call goes to a person. Omitting the parameter is not a way past the rule. - No type coercion.
"600"is a string. A rule comparingamountagainst500will not read it as600; it escalates instead. - The pattern is a bare action name. No
*, norefund.create(order_1). Narrowing is what the conditions are for.
A site with no authorization rules behaves exactly as it did before they existed. a.resolvedBy is "policy" or "human", and
a.reason is written to be readable by a model as well as by you.
A rule has to name the action
Policy can only answer allow or deny when one of your
permission rules names the action. The
all-agents * rule is never applied here. That rule is a statement about your
own coding agents, and it must not silently authorize a refund, so:
| Your rules | authorize({ toolName: "refund.create" }) |
|---|---|
* set to auto-approve | asks a person |
Bash set to auto-approve | asks a person |
refund.create set to auto-approve | allows, nobody paged |
refund.create set to always deny | denies, nobody paged |
| nothing matching | asks a person |
Anything unnamed goes to a person. That is the fail-safe direction: a missing rule, an unknown action and an empty policy all end the same way.
Add a target to narrow further. refund.create(order_4471) beats a bare
refund.create, the same way a specific rule beats a general one everywhere else.
What gets recorded
An allow or deny that policy settled writes its own audit row, marked as
resolved by policy rather than by a person, naming the rule that decided. A
decision that went to a person is recorded by the decision itself as it always
was. Nothing is authorized without leaving a row.
authorize evaluates and returns. It does not create the decision when it
defers, which is why the SDK makes a second call in that case. decisions.ask()
is unchanged and keeps working exactly as before.
The framework adapters use the same boundary
If you gate tools with @pushary/ai-sdk, @pushary/eve, @pushary/mastra,
@pushary/langgraph, @pushary/openai-agents or the shared kernel, the gate now
consults the same policy before it asks anyone. A rule that names the tool resolves
the call with nobody paged; everything else asks exactly as it did. The gate reads the
tool's own arguments as parameters when they are plain scalars, so a threshold rule
works without you plumbing anything, and sends none of them when the input will not
fit, which escalates rather than resolving on a half-read rule.
createGate({ policy: false }) (create_gate(policy=False) in Python) restores the
always-ask gate. decisions.ask() and the explicit ask helpers are untouched: a
deliberate question always reaches a person.
If you want the verdict alone and will handle escalation yourself, call
pushary.evaluateAuthorization({ toolName }) (evaluate_authorization in Python). It
returns { verdict, policy, reason, authorizationId } and opens no decision.
Reach the person without waiting on push enrollment
Push is the fastest channel, not a prerequisite. Ask before you park a run on a decision nobody will see:
const reach = await pushary.reachability(user.id)
// { deliverability: "push" | "fallback" | "unreachable", ... }deliverability | What it means | What to do |
|---|---|---|
push | An enrolled device or web subscription exists | Open the decision. Pushary delivers it. |
fallback | Nobody is enrolled, but you know this end-user | Open it with approvalUrl: true and deliver the link yourself |
unreachable | No end-user was named | There is nobody to send it to. A link does not fix this. |
Three states rather than a boolean, because "not push-reachable" and "unreachable" want different responses, and collapsing them is how an action gets parked for somebody you could have emailed in a second.
Deliver the link over the channel you already have
const decision = await pushary.decisions.create({
question: "Approve a refund?",
externalId: user.id,
approvalUrl: true,
})
await sendEmail(user.email, decision.approvalUrl)The link is bound to that decision, that site and that recipient, and it expires — twelve hours, or when the decision does, whichever is sooner. Pasted at another decision's URL it fails; edited in any field it fails; kept past the deadline it fails.
Off by default, because a capability URL returned on every create is a capability URL in every log, for the majority of decisions that never needed one.
It widens who can be reached, never who may answer
An answer over the link is attributed to the end-user it was minted for, as an external actor — never a Pushary user. So a decision routed to named workspace members still refuses it, and the routing rules you already wrote keep meaning what they meant.
Only one answer ever lands, whichever way it arrives: the durable compare-and-set that settles a decision is the same one the phone goes through.
Say what the action does, not what the tool is called
A generated question asks somebody to authorize a payment by reading JSON:
Approve issue_refund? {"amount":4800,"currency":"EUR"}. Send a presentation
instead and every surface renders the same thing in business language.
await pushary.decisions.create({
question: "Approve a refund?", // still sent, still shown
externalId: user.id,
toolName: "refund.create",
toolTarget: "order_4471",
parameters: { amount: 4800, currency: "EUR" },
presentation: {
label: "Refund order #4471",
effect: "Returns EUR 48.00 to the customer's card. This cannot be undone.",
changes: [
{ parameter: "amount", label: "Refund amount", format: { kind: "currency", currency: "EUR" } },
],
risk: "The order was already marked delivered.",
},
})A change names a parameter, it does not carry a value
This is the part worth understanding. changes[].parameter is a key of
parameters, and the value is read from there. So the amount a person sees is
the amount a rule compared against — they cannot be different numbers, because
there is only one number.
Carrying a value here instead would let an action show EUR 48.00 while the rule
that approved it read 90000, and the approval would be evidence of something
that did not happen.
Formats are explicit, never inferred
4800 is a quantity, a price in cents, and a timestamp. Guessing is how a surface
shows "4800" for a refund of forty-eight euros.
format.kind | The parameter holds | Renders as |
|---|---|---|
currency | minor units, a whole number, plus an ISO 4217 code | EUR 48.00, JPY 4800, -EUR 48.00 |
quantity | a number, plus an optional unit | 3, 3 items |
timestamp | epoch milliseconds or an ISO 8601 string | 2026-08-22 14:30 UTC |
boolean | a boolean | Yes / No |
text | a string | as sent |
Money is minor units because a float cannot hold it exactly and a pre-formatted string cannot be compared by a rule. Times are UTC because two people in two timezones must not read one deadline differently. Nothing is locale-formatted: the phone, the decision page and Slack print identical strings, pinned by a shared fixture each surface's tests assert against.
It is refused, not half-rendered
A presentation that names a parameter you did not send, names one whose name
marks it secret (api_key, password), or gives a value its format cannot render
comes back 400 with the reason. A partially rendered action is exactly the
"approve something you cannot fully see" problem this exists to remove.
Sending no presentation is fine and unchanged: you keep the free-text
question you have always sent. Only a caller that claims a presentation is held
to it.
Run it once, and prove it ran
A verdict says an action may happen. It does not say it happened once. Between the two sit every framework retry, every resumed run and every concurrent worker, and a refund authorized once and issued twice cannot be taken back.
protect() closes that gap for you. It spends the approval against a durable permit
before it calls run, and records what came of it afterwards:
const protect = kernel.protect({ apiKey: process.env.PUSHARY_API_KEY! })
const outcome = await protect({
action: 'refund.create',
target: order.id,
externalId: customer.id,
facts: { amount: order.amountMinor, currency: 'EUR' },
callId: toolCall.id,
runId: thread.id,
run: () => stripe.refunds.create({ charge: order.chargeId }),
})
if (!outcome.ok) return outcome.reason // policy denied, nobody answered, or already runNothing changes in what you write. What changes is that a second call with the same approval is refused rather than issuing a second refund.
The action approved is the action that runs
The permit is bound to the exact subject that was authorized: the action, its target, the actor, the environment, the end-user and every fact you sent. Change any of them between the approval and the execution and there is no permit for the action you now want to run, so it does not run.
That is not a check you can forget to make. The server digests what you claim you are about to do and what it recorded, and refuses unless they are the same action.
A retry cannot duplicate a side effect
The permit is claimed in a single statement against a unique index. Two workers, a
framework retry and a resumed run all reach the same permit and exactly one of them
proceeds. The rest come back 409 with refusal: "already_consumed" and, through
protect(), a reason worded to stop the model rather than invite another attempt.
A permit with no receipt is a real state, not a gap
protect() records succeeded or failed against the permit when run returns or
throws. A process that dies in between leaves the permit running — which is the
honest record: the boundary cannot tell whether the side effect landed, and guessing
either way is how a refund is issued twice or never at all. Those are the rows a
reconciliation looks at.
An error thrown by run is still thrown, never folded into ok: false. A denial
and a failed refund want opposite next moves, and an agent reading a boolean cannot
tell them apart.
Doing it by hand
If you own your own execution path, the two calls are public:
curl -X POST https://pushary.com/api/v1/server/authorizations/consume \
-H "Authorization: Bearer $PUSHARY_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"authorizationId": "the decisionId, or the authorizationId from /authorize",
"toolName": "refund.create",
"toolTarget": "order_4471",
"externalId": "cust_7",
"parameters": { "amount": 4800, "currency": "EUR" }
}'{
"permitId": "b2c1…",
"authority": { "kind": "human", "answeredAt": "2026-08-22T12:00:04.000Z" },
"expiresAt": "2026-08-22T13:00:00.000Z"
}Then, when you know what happened:
curl -X POST https://pushary.com/api/v1/server/authorizations/$PERMIT_ID/receipt \
-H "Authorization: Bearer $PUSHARY_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "outcome": "succeeded", "summary": "re_1abc" }'A refusal comes back 409 (or 404 if the authorization is not one this key can
name) with a refusal you can branch on:
refusal | What happened |
|---|---|
not_authorized | No settled authorization with that id, or it was not approved |
action_mismatch | The action is not the one that was authorized |
expired | The approval was not spent within the hour |
already_consumed | Something already spent it. executionState says what that run did |
authority is frozen when the permit is claimed and never updated. A rule can be
edited and a member can lose their seat; an authorization that can only say "something
allowed this" is not an audit trail.
Prove it before you pay: the sandbox
Get a sandbox credential with POST /api/agent/sandbox while signed in to the
dashboard. It needs no plan, because it cannot produce a production side effect.
curl -X POST https://pushary.com/api/agent/sandbox \
-H "Cookie: $YOUR_DASHBOARD_SESSION"
# { "siteId": "...", "key": "pk_....sk_...", "reaches": [...] }The secret is returned once. GET the same route to check whether a workspace
already has a sandbox; it never returns a key.
A sandbox key is an ordinary key on your workspace's sandbox site, so the
isolation is the tenant isolation you already have: separate site, separate
policy rows, separate ledger. It reaches four surfaces and refuses the rest with
a 403 and code: "forbidden":
| Reaches | Refuses |
|---|---|
authorize | enroll, ask, send, notifications |
authorization-rules | campaigns, subscribers, templates, flows |
decisions | keys, site, channels, machines, webhook-secret |
identity |
The decision leg is real: same durable row, same ledger entry, same idempotency, same presentation. What is replaced is the delivery and the person. Nothing is pushed, no Slack message is sent, no phone rings, and the decision is settled by a simulator whose answer you choose:
await pushary.decisions.create({
question: "Approve a $480 refund for order #4471?",
externalId: user.id,
toolName: "refund.create",
wait: true,
sandboxOutcome: "deny", // "approve" (default) | "deny" | "no_answer"
})no_answer is the one worth reaching for on purpose: it leaves the decision open
so you can see what your own code does when nobody answers, which is the case
fail-closed semantics exist for and the one nobody rehearses against a real phone.
Sandbox decisions appear in your ledger marked as answered by the sandbox, never as answered by a person, so the evidence stays honest. They spend no production notification quota; the per-workspace rate limit still applies.
Per-end-user keys (multi-tenant)
If you drive one shared agent for many end-users, mint a key bound to a single end-user for that session, then hand it to the agent. A bound key can only create or resolve decisions and enroll that exact user, so a prompt-injected agent acting for user A can never reach user B, even over the raw API or MCP.
// On your backend, with your own (unbound) Partner key:
const session = await pushary.keys.issue({ externalId: user.id, expiresInSeconds: 3600 })
// Hand session.apiKey to the agent runtime for this user. It is shown once.
// ...when the session ends:
await pushary.keys.revoke(session.keyPrefix)With a bound key, externalId is implied everywhere: omit it on create_decision
and enroll, and any value the agent supplies is ignored in favor of the bound one.
If nobody answers
The decision expires at expiresInSeconds. Your workflow decides what that means
(proceed, block, escalate). Handle the expired status; never hang a run forever.
What people get wrong
- Blocking a serverless agent on a human. It times out. Use create-and-resume.
- Reachability is a real step. No connected channel, no ask. A decision for a
user who never enrolled a phone, subscribed to web push, or has no Slack route
opens, reaches nobody, and expires unanswered. Enroll them first, or pass
requireReachable: trueto fail fast with a 409 instead of asking a ghost. - Sending the enroll link only as a tap, to the phone that will install the app. An end-user without the app gets Safari, then the App Store, and iOS never hands the link back afterwards. Give them something still scannable — the QR on another screen, or a link they can open twice — and they finish with "Invited by a company?" in the app. See what your end-user sees.
- Who is answering? The hosted page link is bearer auth. For anything sensitive, have the user answer inside your own authenticated app and relay it.
- Verify webhooks. Never act on an unverified callback.
- Approval fatigue. Ask only for consequential actions; auto-allow the rest.
- Idempotency is not optional. Agents retry.
decisions.ask()generates a key for you, but the rawdecisions.create()and the MCPcreate_decisiontool do not — you must pass your own stableidempotencyKey(orIdempotency-Keyheader) on those paths, or a retried create double-asks the same human.
Limits
Partner plan: up to 25,000 end-users and 100,000 notifications/month, 365-day retention on the decision log. A decision stays open up to 24h; up to 200 pending at once per site.