# Bubblio — the full integration file # https://bubblio.dev/llms-full.txt # # Read this one file and you can put an agent door on a platform end to end. # Shorter index: https://bubblio.dev/llms.txt · typed HTTP contract: # https://bubblio.dev/docs/openapi.yaml · human docs: https://bubblio.dev/docs ================================================================ 1 · WHAT BUBBLIO IS ================================================================ Bubblio puts a door on a product that AI agents can find, knock on, and walk through — with their human's one-tap blessing, and payment on the platform's own Stripe. One install, two doors into one support core (same brain, same knowledge, same receipts): - The widget + email channel: customer service for human visitors. - The agent door: a stateless MCP endpoint for visiting AI agents. Asking is free, anonymous, grounded in the platform's own knowledge with citations. Actions — functions the platform's code declares — run through quote → confirm, execute exactly once, and are receipted. Authority doctrine: code declares shapes; only the owner, in the dashboard, opens anything. A deploy can never open an action; closing one never needs a deploy. Pricing (beta): free door (questions tier, directory listing, agent keys) · Teammate $29/month flat (widget + email + full door: actions, payments, mandates; fair use ~500 visitor conversations/month, agent traffic uncounted). Bubblio takes no cut of door payments — money moves buyer → the platform's own Stripe. Access is by request (private beta): https://bubblio.dev/signup Until an account is approved, its door's URLs answer 404 "No agent door here." — uniformly, so closed and nonexistent are indistinguishable. ================================================================ 2 · THE PUBLIC MACHINE SURFACE ================================================================ GET https://api.bubblio.dev/agent/directory every listed door + hub block GET https://api.bubblio.dev/agent/feed.json ACP-compatible rows (no MCP needed) GET https://api.bubblio.dev/agent/hub hub manifest (stable 6-tool surface) POST https://api.bubblio.dev/agent/hub/mcp the one connector: 5 door tools + find GET https://api.bubblio.dev/agent/{doorId} a door's manifest (caps, prices, rails) GET https://api.bubblio.dev/agent/{doorId}/llms.txt a door's plain-text signpost POST https://api.bubblio.dev/agent/{doorId}/mcp a door's MCP endpoint MCP endpoints are STATELESS JSON: POST one JSON-RPC 2.0 message per request, receive one application/json response. No SSE, no session handshake. Methods: initialize, ping, tools/list, tools/call. The bare knock: curl -X POST https://api.bubblio.dev/agent/{doorId}/mcp \ -H 'content-type: application/json' \ -d '{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"about","arguments":{}}}' ================================================================ 3 · QUICKSTART — ZERO TO OPEN DOOR (~10 MINUTES) ================================================================ Install (the door needs only the server SDK; the chat widget is separate): npm install @bubblio/server # + `stripe` only if you declare prices # the `sync` route option below requires @bubblio/server 0.13+ Environment: BUBBLIO_API_KEY=bbl_… # dashboard → Keys SITE_URL=https://your-site.com # public origin (Vercel: set explicitly — # deployment URLs sit behind auth walls) STRIPE_SECRET_KEY=sk_… # only for priced actions; never sent to Bubblio NEXT_PUBLIC_STRIPE_PK=pk_… # only for the stripe_spt rail's grant target The complete reference integration is four files (Next.js App Router shown; any Node backend works — the SDK returns standard (Request) => Response handlers). Replace the @/lib/your-app and @/lib/auth imports with real modules. ---- FILE 1/4 · lib/bubblio/door.ts ---------------------------- // lib/bubblio/door.ts — the door in ONE file: each action's shape (description, // parameters, price) lives next to its handler, and `door.sync` pushes the shapes // to Bubblio on deploy. Shapes only — synced actions always arrive with policy // 'off'; the Bubblio dashboard is the ONLY authority that opens one, and the // *Suggestion fields below are hints it displays, never settings it applies. import { defineDoorActions } from '@bubblio/server' // Your real modules go here — the door delegates to the SAME functions your app // (and your Bubblio widget tools, if you have them) already use, so an agent with // a valid key IS that user: same credits, same rate limits, same code path. import { getCredits, generateImage } from '@/lib/your-app' /** The identity shape your agent-keys route signs at mint (app/api/agent-keys). */ type DoorUser = { id: string; email?: string } // A keyed confirm can still resolve a NULL user: the key's signed identity lives // ~90 days and dies with a Bubblio API-key rotation. Answer calmly and actionably // — never with UI instructions an agent can't follow ("tap the button") and never // by throwing (a throw reads as your platform being broken, not the key expiring). const IDENTITY_EXPIRED = { ok: false, message: 'This agent key is no longer linked to an account — its signed identity has expired. ' + 'Ask the account owner to sign in on our site, mint a fresh key, and update the agent.', } export const door = defineDoorActions({ actions: { check_credits: { description: 'Get the current credit balance for the connected account', policySuggestion: 'keyed', // hint only — the dashboard's switch decides handler: async (_args, user) => user ? { credits: await getCredits(user.id) } : IDENTITY_EXPIRED, }, generate_image: { description: 'Generate an image from a text prompt', parameters: [ { name: 'prompt', type: 'string', description: 'What to generate', required: true }, ], policySuggestion: 'keyed', // The price is ONE LINE (@bubblio/server ≥0.12). Synced as shape, shown on // every discovery surface, and PINNED on each quote — an agent never pays // more than quoted. It never opens anything (policy still rules), and it // requires the `payments` config on the tools route to actually charge. price: '$0.50', // Suggest the matching approval control: silent spend only under a standing // mandate the human granted — otherwise the quote asks them on their phone. approvalSuggestion: 'mandate', timeoutSeconds: 15, // generation is slow; the confirm window caps at 20s handler: async (args, user) => user ? generateImage(user.id, String(args.prompt)) : IDENTITY_EXPIRED, }, }, }) ---- FILE 2/4 · app/api/bubblio/tools/route.ts ----------------- // app/api/bubblio/tools/route.ts — the callback route Bubblio POSTs, server-to- // server: widget-session tools (if you have the chat bubble) and agent-door // confirms share it. createBubblioToolRoute verifies the replay-protected HMAC, // resolves the per-user identity from the signed body (body.ctx), dispatches to // the matching handler, and — for priced actions — runs the charge-wrap. import { createBubblioToolRoute } from '@bubblio/server' import { door } from '@/lib/bubblio/door' export const POST = createBubblioToolRoute({ bubblioApiKey: process.env.BUBBLIO_API_KEY!, // Required once ANY action declares a price: charge on YOUR OWN Stripe → // run the handler → auto-refund on throw, idempotency-keyed by quote id. // The secret key stays in your environment; Bubblio never sees it. payments: { stripeSecretKey: process.env.STRIPE_SECRET_KEY! }, tools: { ...door.handlers /* , …your widget-session tools */ }, // The sync option (#49): the SDK fires door.sync on the FIRST request — never // at module scope (`next build` evaluates route modules, so a module-scope // sync would run inside every build, against build-time env, on a machine // that may have no network), once per process, deduped while in flight, and // retried on failure. Sync is idempotent, so this keeps the registry fresh // on every deploy with zero hand-rolled plumbing. sync: { door, callbackUrl: `${process.env.SITE_URL}/api/bubblio/tools`, // Phone connect: your login page, so an agent that hits a keyed action // WITHOUT a key can send its human here to connect from their phone — // the page forwards the grant, your key route mints server-side, nobody // copies a key. Path-only by design: it resolves against callbackUrl's // origin and can never point off your site. See app/connect-agent. connectPath: '/connect-agent', // The payment profile for priced actions: which rails you accept, and — // for the wallet rail — whom to grant tokens to (your PUBLISHABLE key; // Bubblio refuses sk_/rk_ values outright). Distinct from `payments` // above: that one holds your SECRET key, locally, for the charge-wrap. payments: { rails: ['payment_link', 'stripe_spt'], stripePublishableKey: process.env.NEXT_PUBLIC_STRIPE_PK!, }, }, }) ---- FILE 3/4 · app/api/agent-keys/route.ts -------------------- // app/api/agent-keys/route.ts — the entire "Connect your AI agent" backend. // GET lists the signed-in user's keys, POST mints one (the raw bak_ key appears // exactly once, in that response — show it, don't store it), DELETE revokes, // scoped to the user's own keys. Key custody, identity signing, and revocation // all live on the Bubblio API behind createAgentKeyRoute; you supply ONLY your // session lookup. // // POST { grant } is the phone-connect flow (see app/connect-agent): the key is // minted and forwarded straight into Bubblio's connect grant — the response is // { ok, connected, id }, never the raw key. If you ever wrap this POST with your // own logic (per-user key caps, telemetry), pass grant requests through UNTOUCHED // after mint — the key is already forwarded, and a compensating revoke would // strand an approved connection with a dead credential. (Detecting a grant // request needs request.clone(): the SDK consumes the body.) import { createAgentKeyRoute } from '@bubblio/server' // Your real session lookup — the ONLY integration point. import { getSession } from '@/lib/auth' /** e.g. an***@example.com — display-only, shown next to the key in lists. */ function maskEmail(email: string): string { const [local, domain] = email.split('@') return `${local.slice(0, 2)}***@${domain}` } const route = createAgentKeyRoute({ bubblioApiKey: process.env.BUBBLIO_API_KEY!, getUser: async (req) => { const session = await getSession(req) if (!session) return null // → 401 // Everything here EXCEPT `hint` is signed into the key's identity and becomes // the `user` argument in your door handlers on every keyed confirm. Keep it // small — an id and a field or two, exactly like withUserContext. return { id: session.user.id, email: session.user.email, hint: session.user.email ? maskEmail(session.user.email) : undefined, } }, }) export { route as GET, route as POST, route as DELETE } ---- FILE 4/4 · app/connect-agent/page.tsx --------------------- 'use client' // app/connect-agent/page.tsx — where phone connect lands. When an AI agent hits // one of your keyed door actions WITHOUT a key, Bubblio sends the agent's human // here — on their phone — with ?bubblio_grant=g_… in the URL. After sign-in this // page POSTs the grant to /api/agent-keys, which mints the bak_ key server-side // and forwards it into the grant; the waiting agent receives it through its // poll, exactly once. The key never reaches this browser. // // Two traps this page defuses, both hit by the first live integration: // 1. THE QUERY STRING DOES NOT SURVIVE OAUTH. Most sign-in flows rebuild their // return URL from pathname alone, silently dropping ?bubblio_grant on the // round-trip. So: stash the grant in sessionStorage the moment the page // loads, BEFORE any sign-in redirect, and restore it on the return leg // (same tab — sessionStorage survives the hop to the provider and back). // 2. THE HUMAN ARRIVES SIGNED OUT. Render your auth UI in place (a modal or // inline form) instead of redirecting to a login route — a redirect strands // the grant unless every return URL keeps the query string (see trap 1). // // location.search (not useSearchParams) is deliberate: useSearchParams demands // a Suspense boundary at build time; reading window.location in an effect // skips the ceremony. import { useCallback, useEffect, useRef, useState } from 'react' // Your real auth pieces — the only app-specific imports on this page. import { useAuth, SignInUI } from '@/lib/auth-client' const GRANT_STASH_KEY = 'bubblio_connect_grant' type GrantStatus = 'connecting' | 'connected' | 'error' export default function ConnectAgentPage() { const { user, loading } = useAuth() const [grant, setGrant] = useState(null) const [status, setStatus] = useState(null) const [error, setError] = useState(null) const startedFor = useRef(null) // Pick up the grant from the URL (fresh arrival from the Bubblio approval // page) or from sessionStorage (return leg of a redirect that dropped it). useEffect(() => { const fromUrl = new URLSearchParams(window.location.search).get('bubblio_grant') if (fromUrl) { sessionStorage.setItem(GRANT_STASH_KEY, fromUrl) setGrant(fromUrl) return } setGrant(sessionStorage.getItem(GRANT_STASH_KEY)) }, []) const connect = useCallback(async (g: string) => { setStatus('connecting') setError(null) try { const res = await fetch('/api/agent-keys', { method: 'POST', headers: { 'Content-Type': 'application/json' }, // label names the key in lists, so phone-connected keys aren't anonymous. body: JSON.stringify({ grant: g, label: 'Phone connect' }), }) const data = await res.json().catch(() => null) if (!res.ok || !data?.connected) { // Failure shape: { error } with the upstream status. A transient failure // retries cleanly with the same handle; a spent/expired grant keeps // failing — the human asks the agent for a fresh link. setStatus('error') setError(data?.error ?? 'Could not connect your agent. Try again.') return } // Minted and forwarded — the grant is spent. Clear the stash so a page // refresh doesn't retry a dead handle. sessionStorage.removeItem(GRANT_STASH_KEY) setStatus('connected') } catch { setStatus('error') setError('Could not connect your agent. Check your connection and try again.') } }, []) // Auto-connect once signed in. The ref guards double-fire (dev StrictMode // re-runs effects; user/grant identity changes re-trigger this one). useEffect(() => { if (!user || !grant || startedFor.current === grant) return startedFor.current = grant void connect(grant) }, [user, grant, connect]) // Minimal chrome — restyle freely; the STATES are the contract. const card = { maxWidth: 420, margin: '15vh auto', padding: 32, textAlign: 'center' as const } return (
{!grant ? ( <>

Connect your AI agent

This page finishes connecting an AI agent to your account. Follow the link your agent gave you — it carries the one-time connect handle.

) : loading || status === 'connecting' || (user && !status) ? ( <>

Connecting your agent…

Minting a key for your account and handing it to your agent. This takes a second.

) : status === 'connected' ? ( <>

Connected

Your agent's key is on its way — hop back to the Bubblio tab to approve. You never need to see or copy the key, and you can close this page.

) : status === 'error' ? ( <>

Connection failed

{error}

) : ( <>

Almost connected

Sign in to finish connecting your agent — the key is minted for your account and handed straight to the agent.

{/* IN PLACE, not a redirect (trap 2 above). */} )}
) } ---- AFTER DEPLOYING ------------------------------------------- 1. The first request syncs action shapes to Bubblio (the `sync` option — build-safe, once per process, retried on failure; sync is idempotent and pushes SHAPES ONLY, never policy). 2. In the dashboard: enable the door (one toggle — questions only until you say otherwise). Every synced action arrives HIDDEN and waits for the owner's per-action decision: hidden → keyed (agent key required) → any. Next to policy sits the approval control: auto | mandate | always. 3. Test: register https://api.bubblio.dev/agent/hub/mcp as an MCP connector (or `claude mcp add door --transport http https://api.bubblio.dev/agent/{doorId}/mcp`, or the raw curl knock above). {doorId} and the door's exact URLs live on the dashboard's Agent door page. Every visit shows in the dashboard as a real conversation. ================================================================ 4 · THE QUOTE → CONFIRM CONTRACT ================================================================ - quote { tool, args, agent_key? } pins the exact call (canonical-JSON hash of tool+args) for 10 minutes — 30 while a human approval or a payment link is in the loop — and returns a quote_id. Nothing executes at quote. For priced actions the pin IS the price guarantee, and the quote always echoes the door's payment profile (payment { rails, stripe_publishable_key?, merchant }) beside the price. Priced quotes always bind to an agent key: money never rides an anonymous bearer path. - confirm { quote_id, agent_key? } executes EXACTLY once: winner-only conditional consume. A duplicate confirm returns the original receipt marked "replayed": true — never a second execution. Policy is re-checked at execution; a keyed quote confirms only with the same agent_key that quoted (a quote_id alone is never a bearer token). - Quotes needing a human answer status "needs_approval" with approval { url, code, expires_at } and a relay_script the agent forwards to its human VERBATIM. confirm doubles as the non-consuming poll (wait_seconds ≤ 25) answering awaiting_human / awaiting_payment. - The callback: Bubblio POSTs the HMAC-signed body to the platform's callbackUrl (X-Bubblio-Signature-V2 = HMAC-SHA256 of "timestamp.rawBody", ±5 min; the legacy body-only signature is refused by SDK ≥0.9). Fields: tool, args, agent { tier, keyId | key }, ctx (platform-signed user JWT), idempotencyKey (the quote id), callbackTimeoutSeconds, payment / payment_settled / paymentLinkRequest. The body is EXTENSIBLE — never validate it with a closed schema. Bubblio waits min(timeoutSeconds ?? 10, 20)s; 200+JSON → outcome "ok"; any non-2xx → terminal "platform_rejected"; no answer → "unreachable". - Deferred results: a handler that outlives the window keeps running; createBubblioToolRoute pushes the late result (signed, quote-bound) and the receipt upgrades unreachable → ok. Only that upgrade exists. - Receipts (append-only, held by agent and owner alike): receipt_id (= quote id), tool, args_hash, tier, approval_id?, approved_at?, mandate_id?, price_cents?, currency?, payment?, confirmed_at, outcome ok|platform_rejected|unreachable, replayed?, result_received_at?. - Idempotency, honestly: the hosted callback fires AT MOST once per quote (no retry loop). The double-spend vector is RE-QUOTING — guard it with a per-identity unique claim in the platform's own DB. idempotencyKey is for correlation: the Stripe idempotency key, the results push, the receipt id. ================================================================ 5 · PAID ACTIONS (THE PLATFORM'S OWN STRIPE) ================================================================ - price: '$0.50' (or { amount_cents, currency: 'usd' }) on a declared action. 1¢–$10,000, usd only. A shape: shown on every discovery surface, pinned on every quote; it never opens anything. Sub-$0.50 prices are declarable but uneconomic on card rails (Stripe's fixed fee) — know your margin. - The charge-wrap: payments { stripeSecretKey } on createBubblioToolRoute = charge on the platform's own Stripe → run handler → auto-refund on throw. Quote id = Stripe idempotency key; refunds run under quoteId + ':refund'. The payment token is TRANSIT-ONLY: never stored, logged, echoed, or handed to the handler. Raw card numbers are hard-refused. Mid-charge connection death: one retry under the same idempotency key, else "outcome unknown" (the PaymentIntent carries bubblio_quote metadata). - Rails (door.sync payments { rails, stripePublishableKey } — pk_ only; sk_/rk_ refused by name): · payment_link — the quote carries a Stripe Checkout URL minted on the platform's account via the callback's paymentLinkRequest (answer { url, ref }); the human pays; the platform's checkout webhook calls completeBubblioCheckout(session, opts) to settle; refused settlements are auto-refunded under quoteId + ':refund:' + sessionId. · stripe_spt — an amount-capped Shared Payment Token, charged at confirm; spends silently ONLY under a live standing mandate (use approvalSuggestion: 'mandate'). - Guarantees: charged amount always derives from the pin, never from a caller; a settlement mismatching amount or args_hash is refused and refunded; declaring a price against a payments-lagging API refuses the WHOLE sync (fail-closed). ================================================================ 6 · CONSENT & PHONE CONNECT ================================================================ - Per-action approval control (owner-set; approvalSuggestion is a hint): auto (no ceremony beyond a keyed agent's first visit) · mandate (asks unless a standing mandate covers the run) · always (a human approves every run; mandates never cover it). - The approval page (bubblio.dev/a/{apr_32hex}) renders the SERVER's facts: platform + verified callback origin, pinned action/args/price, the agent's identity (verified via Web Bot Auth, or "presented, unverified"), and a binding code that must match what the agent showed. GETs write nothing; approval is POST-only after a WebAuthn ceremony whose challenge is the SHA-256 of the canonical terms — the first Face ID is enrollment. One approval authorizes exactly ONE execution; declines are final; three declines in 24h cool the caller down. Kill-switch without the agent: bubblio.dev/approvals (passkey sign-in, revoke connections and mandates). - Phone connect (keys without copy-paste): declare connectPath on the sync (path-only; resolves against callbackUrl's origin). A keyed action hit without a key routes the human to the platform's own login; the key route's POST { grant } mints the bak_ key server-side and forwards it into the connect grant; the agent's poll delivers it exactly once with store_this_key: true. TRAPS (both hit in production): OAuth round-trips drop the query string — stash ?bubblio_grant in sessionStorage on arrival (see FILE 4/4); and render auth UI in place — a login redirect strands the grant. On forward failure the just-minted key is revoked and { error } is returned; transient failures retry with the same handle. ================================================================ 7 · MANDATES & BUDGETS (STANDING CONSENT) ================================================================ - Minted by a checkbox on the approval page: ONE passkey assertion signs the approval AND the server-authored mandate terms. Shipped defaults (tunable server-side): $2 per-action cap, $5 daily budget (rolling 24h), 30-day validity, $5 hard stop no mandate can ever exceed. 'always' actions sit above every mandate. - Scope: per platform, bound to a managed bak_ key. Anonymous and custom-custody callers always ask. - Evaluated at quote (covered → silent; uncovered → step-up with remaining budget stated) AND re-read live at confirm pre-flight (revocation/expiry inside the window refuses before anything runs, quote intact). - Fail-closed cents ledger: one spend row per executed confirm, keyed by the quote id, written BEFORE the consume; an unreadable budget sum refuses the confirm. Spend authority lives ONLY in this ledger — never in ctx, never in any token an agent holds. - Revocation: bubblio.dev/approvals, one tap, immediate at the next quote and the next confirm. ================================================================ 8 · KEYS & IDENTITY ================================================================ - Custody split three ways: the platform signs the user's identity once at mint (ctx: a JWT signed with a secret derived from the platform's API key, default 90 days); Bubblio stores the bak_ key HASHED and the ctx ENCRYPTED (it holds only a hash of the API key, so it can neither verify nor forge identity); the agent holds the raw key, shown exactly once. - On keyed confirms the ctx arrives INSIDE the HMAC-signed body (body.ctx) — never in URLs. createBubblioToolRoute resolves the handler's `user` argument from it automatically. The agent block carries only { tier: 'keyed', keyId }. - Key route semantics: GET list (display metadata only) · POST { label? } → { key, id } once · POST { grant } → { ok, connected, id } (phone connect; never the key) · DELETE { id } revoke (immediate; in-flight quotes refuse). - Per-user caps: maxActiveKeysPerUser on createAgentKeyRoute — enforced by Bubblio inside the mint; races fail closed (spurious 409 code 'key_cap_reached'; never an overshoot). - API-key rotation runbook: deploy with previousBubblioApiKeys: [oldKey] (the N/N-1 verify window — local, verification-only), revoke the old key whenever, keep the window up to the ctx lifetime (~90 days), then remove it. Without the window, pre-rotation keyed confirms silently resolve a null user. - Doctrine: ctx carries IDENTITY only; a leaked ctx must never be worth money. Spend-linked key paths should mint short-lived ctx (hours). ================================================================ 9 · SDK EXPORTED API (@bubblio/server) ================================================================ createBubblioSession(config) → Promise<{ sessionId, provider, transport, events, sessionKey? }> // sessionKey is present for Runway sessions only. // Platform mode requires bubblioApiKey + characterId (or personality). // Self-hosted mode requires apiKey + avatarId (your own Runway account). withUserContext({ secret, user, callbackUrl, expiresInSeconds? }, tools) → Promise // Stamps a per-session JWT onto every tool's callbackUrl as `?ctx=...`. verifyBubblioWebhook(request, rawBody, secret, opts?) → Promise // the parsed body, or null when verification fails // Reads X-Bubblio-Signature-V2 + X-Bubblio-Timestamp and checks them against the raw // bytes: replay-protected (±5 min window). A request carrying ONLY the pre-0.9 body-only // X-Bubblio-Signature is REFUSED unless opts.allowLegacySignature is set. // opts: { toleranceMs?, allowLegacySignature?, onFailure? } verifyWebhookSignature(rawBody, signatureHeader, secret, opts?) → boolean // Low-level primitive: constant-time HMAC-SHA256. WARNING — body-only unless you pass // opts.timestamp, and a body-only signature is NOT replay-protected: a captured callback // stays valid forever. Prefer verifyBubblioWebhook above; reach for this only when you are // signing/verifying something Bubblio didn't send. // Always call before parsing JSON. createBubblioToolRoute({ bubblioApiKey, tools, callbackSecret?, previousBubblioApiKeys?, payments?, sync?, deferAfterSeconds?, allowLegacySignature?, onFailure?, bubblioApiUrl? }) → (request: Request) => Promise // The verified callback route: checks the replay-protected V2 signature, resolves the // handler's `user` from the signed body's ctx (callbackSecret; previousBubblioApiKeys is // the rotation window), and dispatches tools[name](args, user, meta). // payments { stripeSecretKey | client }: the charge-wrap — a paid Agent Door confirm is // charged on YOUR OWN Stripe (quote id = idempotency key) before the handler runs, // auto-refunded under `${quoteId}:refund` when the handler throws. It also answers the // quote-time paymentLinkRequest by minting the Checkout Session for the payment_link // rail. Omit it and a payment-bearing callback is refused before your handler runs. // sync { door, callbackUrl, connectPath?, payments?, retrySeconds? }: build-safe // first-request door sync (once per process, deduped, retried) — see defineDoorActions. // deferAfterSeconds (default 8): handlers that outlive the callback window keep running // and the route pushes the late result itself via pushDoorResult. userContextFromRequest(secret, request, body?) → Promise // Verifies the user-context JWT and returns the typed user. Reads `body.ctx` first when // you pass the HMAC-verified body (the 0.10 signed-body placement for managed-key // confirms), else falls back to `?ctx` in the request URL. // null = guest token; throws on missing/invalid. signUserContext(secret, user, expiresInSeconds?) verifyUserContext(secret, token) // Lower-level JWT helpers (HS256). Use the helpers above unless you have a // custom transport. publicOrigin(request) → string // Resolves the callback origin. See "Deploying to Vercel" above. createAgentKeyRoute({ bubblioApiKey, getUser, bubblioApiUrl?, expiresInSeconds?, maxActiveKeysPerUser? }) → (request: Request) => Promise // The one-route "Connect your AI agent" backend: GET=list, POST=mint, DELETE=revoke, // scoped to getUser(request). POST { grant: 'g_…' } is the phone-connect flow: mint, // forward the key into the Bubblio grant, return { ok, connected, id } — never the key. // maxActiveKeysPerUser (#49): per-user active-key quota, enforced by Bubblio inside the // mint (grant path included) — at cap: 409 { code: 'key_cap_reached' }. // See "Agent door" above. mintAgentKey({ bubblioApiKey, user, label?, userHint?, expiresInSeconds?, maxActiveKeysPerUser?, bubblioApiUrl? }) → Promise<{ key, id }> // the raw bak_ key — returned exactly once listAgentKeys({ bubblioApiKey, externalUserId?, bubblioApiUrl? }) → Promise revokeAgentKey({ bubblioApiKey, id, bubblioApiUrl? }) → Promise // Lower-level managed-key helpers behind createAgentKeyRoute. defineDoorActions({ actions: { [name]: { description, parameters?, policySuggestion?, approvalSuggestion?, price?, timeoutSeconds?, handler } } }) → { handlers, sync } // handlers: spread into createBubblioToolRoute's `tools`. // price: '$0.50' or { amount_cents, currency: 'usd' } — the whole monetization // interface: synced as SHAPE, pinned on every quote, charged from the pin alone. // Priced actions always require an agent key. approvalSuggestion ('auto' | 'mandate' | // 'always') hints the dashboard's approval control; like policySuggestion it is // displayed, never applied. // sync({ bubblioApiKey, callbackUrl, connectPath?, payments? }): push shapes to Bubblio // (idempotent; shapes only, never policy). connectPath declares your phone-login page // for the connect flow; payments { rails, stripePublishableKey? } declares the door's // payment profile (rails: 'payment_link' | 'stripe_spt'; pk_ only — sk_/rk_ refused). // Returns { synced, orphaned, unchanged, pendingDecision, warnings }. // Most routes don't call it directly anymore: pass `sync: { door, callbackUrl, … }` to // createBubblioToolRoute (#49) and the route runs it build-safely on the first request. pushDoorResult({ bubblioApiKey, quoteId, result, payment?, bubblioApiUrl? }) → Promise<{ upgraded: boolean }> // Push a late Agent Door result onto its quote (V2-signed with your webhook secret, // auto-fetched). Only unreachable → ok exists; { upgraded: false } means the callback // response made it in time after all. createBubblioToolRoute calls this automatically // past deferAfterSeconds — call it yourself only from your own job queue. `payment` is // the settled-charge summary ({ rail, amount_cents, currency, ref }) for paid actions // you charged yourself; it must match the quote's pin. resumePaidQuote({ bubblioApiKey, quoteId, argsHash?, payment, bubblioApiUrl? }) → Promise<{ settled: boolean, already?, reason? }> // Mark a payment_link quote's payment settled — the agent's next confirm poll executes. // V2-signed like pushDoorResult. reason ('expired' | 'already_settled' | 'not_payable' | // 'conflict' | 'amount_mismatch' | 'args_mismatch') on refusal; every refund-worthy // reason means the payment bought nothing. Usually called via completeBubblioCheckout. completeBubblioCheckout(session, { bubblioApiKey, payments?, bubblioApiUrl? }) → Promise<{ settled: boolean, ignored?, already?, refunded?, reason? }> // The one-call webhook half of the payment_link rail: feed it every // `checkout.session.completed` event object from your Stripe webhook. Non-Bubblio // sessions are ignored (safe on a shared webhook); a REFUSED settlement is auto-refunded // on your Stripe under `${quoteId}:refund:${sessionId}` — but only when you pass // `payments` (your BubblioPaymentsConfig); omit it and refusals are reported, not // refunded. paramsToJsonSchema(parameters?) → { type: 'object', properties, required } // The param-array → JSON Schema conversion sync uses; exported for tests/custom flows. ================================================================ 10 · HTTP ENDPOINT REFERENCE (SUMMARY) ================================================================ Auth flavors: none (public /agent/**) · bbl_ key (Bearer, server-side /v1/**) · signed push (bbl_ key + X-Bubblio-Signature-V2/X-Bubblio-Timestamp over "timestamp.rawBody" with the webhook secret) · consent token (Bearer JWT from POST /approvals/session). Uniform misses per surface ("No agent door here." / "No approval here."); calm one-sentence errors with a machine `code` field. Discovery (none): GET /agent/directory · GET /agent/feed.json · GET /agent/hub · GET /agent/hub/llms.txt · GET /agent/{doorId} · GET /agent/{doorId}/llms.txt MCP (none): POST /agent/hub/mcp (about, ask, list_actions, quote, confirm + find; door by argument) · POST /agent/{doorId}/mcp (GET answers 405; bodies ≤64KB; 429s carry the real limit in the message) Approvals (none unless noted): GET /approvals/a/{id} (facts; writes nothing) · POST /approvals/a/{id}/options (mode auth|register) · POST /approvals/a/{id}/approve (WebAuthn credential; mandate: true also mints the offered mandate) · POST /approvals/a/{id}/decline · POST /approvals/session/options · POST /approvals/session → { token } · [consent token] GET /approvals/mine · POST /approvals/mine/{id}/revoke · POST /approvals/mine/mandates/{id}/revoke Managed keys (bbl_ key): POST /v1/agent-keys { ctx, label?, externalUserId?, userHint?, max_active_per_user? } → 201 { key, id } once · GET /v1/agent-keys · DELETE /v1/agent-keys/{keyId} · POST /v1/agent-keys/connect-grant { grantId, key, id } Sync (bbl_ key): POST /v1/agent-door/actions/sync { callbackUrl, connectPath?, payments?, actions } → { synced, orphaned, unchanged, pendingDecision, warnings } Signed pushes (signed push): POST /v1/agent-door/results/{quoteId} (unreachable → ok only; result ≤32KB) POST /v1/agent-door/paid/{quoteId} (payment-link settlement; refused on amount/args mismatch) Full typed contract: https://bubblio.dev/docs/openapi.yaml ================================================================ 11 · ETIQUETTE FOR VISITING AGENTS ================================================================ - Respect the published caps — every ceiling is in the manifest. - Relay approval material VERBATIM (the link must stay tappable; the human checks the binding code). Poll confirm with the same quote_id and the same agent_key you quoted with. - Declines are final. Don't re-ask; don't re-quote to ask again. - Never re-quote around an unresolved confirm: an "unreachable" receipt may still upgrade to "ok" via the platform's late-result push, and a fresh quote is a fresh execution (on paid actions, the double-spend path). - store_this_key: true means keep it — it is delivered exactly once. The poll_token on a connect quote is private; never relay or display it. - Bring your human for the first tap. You never pay more than quoted. ================================================================ 12 · POINTERS ================================================================ Docs: https://bubblio.dev/docs (quickstart, guides, security model, API) Agents: https://bubblio.dev/docs/agents · https://bubblio.dev/agents (directory) OpenAPI: https://bubblio.dev/docs/openapi.yaml Index: https://bubblio.dev/llms.txt Sign up: https://bubblio.dev/signup (private beta — access by request)