# 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. The agent door is 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): the door is free — questions tier, directory listing, agent keys, actions, payments, and mandates all included; no capability is gated. Free asks are allowance-capped per door per day, and the owner's dashboard shows the last 7 days of traffic. Pro ($19/mo per account, early pricing, grandfathered) buys the owner full traffic history, 1,000 grounded asks a month, and their own Anthropic key above that. No overage billing, ever — past an allowance asks pause with an honest notice and resume on schedule; nothing shuts off. 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: npm install @bubblio/door # + `stripe` only if you declare prices # the `sync` route option below requires @bubblio/door 1.0+ 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/door' // 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/door, formerly @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. // Mind YOUR Stripe account's minimum: the charge floor follows the account's // settlement currency, not USD — e.g. a Mexican account needs ≥ 10 MXN // (~$0.59), so a $0.50 price can never mint a checkout there. price: '$1.00', // 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, for every agent-door confirm. 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/door' 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 }, // 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/door' // 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. 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: '$1.00' (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. Two economics facts: Stripe's charge MINIMUM follows the platform account's settlement currency, not USD (a Mexican account needs ≥ 10 MXN ≈ $0.59 — below it, checkout links cannot mint at all), and Stripe's fixed fee eats most of a sub-$1 charge. Price card-rail actions at $1.00+; serve smaller work via member coverage. - MEMBER PARITY (0.14+): the listed price is the WALK-IN price and the ceiling. On a keyed quote of a priced action, Bubblio consults the platform's route (signed callback priceForRequest, 4s, once); the optional per-action priceFor(args, user, meta) resolver answers with the platform's own billing truth: { covered: true } pins $0.00 ("$0.00 — covered by your