Guides
Actions & the quote → confirm contract
An action is a function your code declares that a visiting agent may run. Every run goes through a two-step machine — quote pins exactly what would happen, confirm makes it happen exactly once — and every run leaves a receipt both sides hold. This page is the contract; its invariants are the product.
The map: two doors, one core
One install opens two doors into the same support core — the same brain, knowledge, allowed functions, and receipt log. The widget and email channel serve your human customers; the agent door is a stateless MCP endpoint (https://api.bubblio.dev/agent/{doorId}/mcp) for the AI agents they send. Doors serve about and ask always (free, anonymous, rate-capped, grounded in your own knowledge with citations); list_actions, quote, and confirm appear only while at least one action is visible — a door can't advertise a capability it doesn't have.
The authority doctrine in one line: a deploy can never open a capability; closing one never needs a deploy. Your code declares shapes (defineDoorActions + sync); only you, in the dashboard, set each action's policy (hidden → keyed / any) and approval control. Sync is the one API-key-writable admin route precisely because a shape-only write can't open anything.
The quote is the pin
quote { tool, args, agent_key? } validates policy and pins the exact call — a canonical-JSON hash of tool + args — for 10 minutes (30 while a human approval or a payment link is in the loop). Nothing executes at quote time:
// quote { tool: "generate_image", args: { prompt: "a lighthouse at night" },
// agent_key: "bak_…" } ← priced actions always require a key
// → the pin. These exact args are what confirm will execute — and for priced
// actions the pinned price is what will be charged, never anything sent later.
{
"quote_id": "q_e37f09c41b8d4a52b6a1c07d9e52f8a3",
"tool": "generate_image",
"args": { "prompt": "a lighthouse at night" },
"price": { "amount_cents": 50, "currency": "usd", "display": "$0.50" },
"payment": { "rails": [], "merchant": "Acme" }, // how a charge settles; [] = the
// platform settles its own way
"expires_at": "2026-08-16T02:51:03Z", // ~10 minutes out (30 on the payment-link rail)
"notice": "Confirming executes this action exactly once. A duplicate confirm returns the original receipt, never a second execution. This quote is bound to your agent key: …"
}- A quote made with an
agent_keyis bound to that credential: confirm must re-present the same key, or the quote reads as nonexistent. Aquote_idalone is never a bearer token. - Quotes that need a human first answer
status: "needs_approval"with a link to relay — see Consent & phone connect. - Every rate ceiling is published in the door's manifest, so agents can pace themselves instead of discovering limits as 429s.
Confirm executes exactly once
confirm { quote_id } consumes the quote with a winner-only conditional update. A duplicate confirm — a retry, a crash-replay, two racing clients — returns the original receipt, marked replayed: true, never a second execution. Policy is re-checked at execution: an action closed, orphaned, or re-gated between quote and confirm refuses honestly, without consuming the quote — closing an action takes effect immediately, no deploy involved.
// confirm { quote_id: "q_e37f…", agent_key: "bak_…" } → { result, receipt }
{
"result": { … }, // your handler's stored answer
"receipt": {
"receipt_id": "q_e37f09c41b8d4a52b6a1c07d9e52f8a3",
"tool": "generate_image",
"args_hash": "9f2c…", // canonical-JSON hash of the pin
"tier": "keyed", // priced runs are always keyed;
// "anonymous" exists only unpriced
"price_cents": 50, "currency": "usd",
"confirmed_at": "2026-08-16T02:41:12Z",
"outcome": "ok" // ok | platform_rejected | unreachable
}
}For quotes that aren't executable yet, confirm doubles as the non-consuming poll: pass wait_seconds (≤ 25) and it answers awaiting_human or awaiting_payment with a suggested retry_in until the decision or settlement lands. Polling never consumes the quote.
The callback: your code runs
An executing confirm POSTs to the same HMAC-signed callback route your widget tools use — createBubblioToolRoute verifies the replay-protected V2 signature, resolves the per-user identity from the signed body, and dispatches to your handler:
// What your createBubblioToolRoute receives on a confirm (already verified,
// dispatched, and typed for you) — the signed body's Agent Door facts:
//
// tool, args the pinned call, exactly as quoted
// agent { tier: 'keyed', keyId } (managed) or { tier, key } (custom custody)
// ctx the platform-signed user JWT → your handler's `user` argument
// idempotencyKey the quote id — your charge/dedupe correlation handle
// callbackTimeoutSeconds how long Bubblio waits on THIS delivery
//
// Bubblio waits at most min(timeoutSeconds ?? 10, 20) seconds. Answer 200 with
// JSON → outcome "ok". Any non-2xx finalizes the receipt as "platform_rejected"
// — a terminal outcome no later fix can resurrect.agent in 0.8, ctx in 0.10). Validate the fields you read — never with a closed schema that rejects unknown keys, or the next additive field turns every confirm into a terminal platform_rejected receipt.Deferred results for slow handlers
// A handler slower than the ≤20s window strands the receipt at "unreachable"
// — createBubblioToolRoute keeps running it and PUSHES the late result itself
// (pushDoorResult under the hood: V2-signed, quote-bound), upgrading the
// receipt unreachable → ok. Only that upgrade exists: an answered attempt is
// final, and a late failure has nothing to push.
generate_video: {
description: 'Render a short video (slow)',
timeoutSeconds: 15, // the confirm window still clamps at 20s
handler: async (args, user) => renderVideo(user!.id, args), // may outlive the window
},The push is authenticated with your API key plus a V2 signature over the body with your webhook secret, and the signed body must name the same quote — a captured push for quote A can never replay against quote B. Long-running paid actions carry the payment summary with the late result, so the upgraded receipt still shows the charge.
Idempotency, framed honestly
The hosted callback fires at most once per quote — there is no retry loop, and a duplicate confirm replays the stored receipt without reaching your endpoint. So a handler-side “seen this key before” cache is defense-in-depth for your own retries (a job queue that re-runs, a retried charge), not the primary double-execution defense. The primary double-spend vector is re-quoting — a new quote is a new key, deliberately never deduplicated — and the guard for it lives in your own database: a per-identity unique claim (e.g. a unique index over (user, work-unit)) that fails the second execution. What idempotencyKey is always for is correlation: the Stripe idempotency key on the charge-wrap, the late-result push, and the receipt id the agent holds.