Quickstart · ~10 minutes
Zero to open door
By the end of this page your platform has a live MCP entrance: visiting agents can ask grounded questions for free, and — per action, only where you say so — run functions your code declares, through a quote → confirm machine that executes exactly once. The four files below are the complete reference integration, genericized from the first live production door.
1 · Install
The door needs only the server SDK — the chat widget is a separate, human-facing install:
npm install @bubblio/server # + `stripe` only if you declare pricesThe sync route option below ships in @bubblio/server 0.13+.
2 · The four files
Copy them in, then replace the @/lib/your-app and @/lib/auth imports with your real modules. The door delegates to the same functions your app already uses — an agent with a valid key is that user: same credits, same rate limits, same code path.
lib/bubblio/door.ts — actions, declared next to their handlers
Each action is a schema and a handler in one object. The *Suggestion fields are hints the dashboard displays — never settings a deploy applies.
// 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<DoorUser>({
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,
},
},
})
app/api/bubblio/tools/route.ts — the callback route
Bubblio POSTs here server-to-server, HMAC-signed. The sync option wires first-request shape sync for you: build-safe (never at module scope — next build evaluates route modules), once per process, deduped in flight, retried on failure. Sync pushes shapes only — it can never open anything.
// 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!,
},
},
})
app/api/agent-keys/route.ts — “Connect your AI agent”
One route mints, lists, and revokes agent keys for your signed-in users. Your session lookup is the only integration point.
// 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 }
app/connect-agent/page.tsx — where phone connect lands
When an agent hits a keyed action without a key, its human signs in here — on their phone — and the key is minted and delivered without anyone copying anything. The sessionStorage stash is load-bearing; see Consent & phone connect for the two traps this page defuses.
'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<string | null>(null)
const [status, setStatus] = useState<GrantStatus | null>(null)
const [error, setError] = useState<string | null>(null)
const startedFor = useRef<string | null>(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 (
<main style={card}>
{!grant ? (
<>
<h1>Connect your AI agent</h1>
<p>
This page finishes connecting an AI agent to your account. Follow the link your
agent gave you — it carries the one-time connect handle.
</p>
</>
) : loading || status === 'connecting' || (user && !status) ? (
<>
<h1>Connecting your agent…</h1>
<p>Minting a key for your account and handing it to your agent. This takes a second.</p>
</>
) : status === 'connected' ? (
<>
<h1>Connected</h1>
<p>
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.
</p>
</>
) : status === 'error' ? (
<>
<h1>Connection failed</h1>
<p>{error}</p>
<button onClick={() => grant && connect(grant)}>Try again</button>
</>
) : (
<>
<h1>Almost connected</h1>
<p>
Sign in to finish connecting your agent — the key is minted for your account and
handed straight to the agent.
</p>
{/* IN PLACE, not a redirect (trap 2 above). */}
<SignInUI />
</>
)}
</main>
)
}
3 · Environment
BUBBLIO_API_KEY=bbl_… # dashboard → Keys
SITE_URL=https://your-site.com # your public origin (Vercel: set it explicitly)
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 targetOn Vercel, set SITE_URL to your public production domain — deployment-specific URLs sit behind Deployment Protection and Bubblio's server-to-server callbacks would hit a 401 wall.
4 · Deploy, then decide in the dashboard
Deploy. The first request syncs your action shapes to Bubblio. Then the ceremony — three facts to know going in:
- Access is by request. Bubblio is in private beta: sign up, and until your account is approved, your door's URLs answer
404 — “No agent door here.”to everyone. Approved-and-enabled or nothing; a prober can't tell closed from nonexistent. - Opening the door is one toggle on the dashboard's Agent door page — questions only, until you say otherwise.
- Every synced action arrives hidden and waits for your per-action decision: keep it
hidden, open it to agents carrying a key (keyed), or open it toanyvisitor. Next to the policy sits your approval control —auto,mandate, oralways(a human approves every run on their phone). A deploy can never open an action; closing one never needs a deploy.
5 · Knock on your own door
Your door speaks standard MCP — Streamable HTTP, JSON responses — so any client works. Your door id and exact URLs live on the dashboard's Agent door page; every test visit shows up there as a real conversation.
# One claude.ai connector reaches every open door — yours included:
# claude.ai → Settings → Connectors → Add custom connector
# URL: https://api.bubblio.dev/agent/hub/mcp
#
# The hub speaks the five door tools plus find, door picked by argument:
# find "your platform" → your door card (handle + door id)
# ask { door: "…", question } → your door, exactly as via its own URL
# Same keys, approvals, prices, and caps — the hub is routing, not a bypass.# Point any MCP client at your door. With Claude Code it's one line:
claude mcp add door --transport http https://api.bubblio.dev/agent/{doorId}/mcp
# Then, in a session:
# about → what this platform is, what agents may do here
# ask → grounded answers from your knowledge, with citations
# list_actions → what your code declared (only the policies you opened)
# quote → confirm → execute exactly once, receipted# No MCP client at all? A door is plain HTTP — stateless JSON, one
# JSON-RPC message per POST, no session handshake:
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":{}}}'6 · Hang the street sign
Your door is open — now make it findable from your own domain, because that's where a cold agent starts when a human says “go use your platform”. Two pastes: a /mcp page (a redirect to your door manifest is enough) and a visible “For AI agents” footer link to it. Exact snippets per stack, plus the optional llms.txt: Hang the street sign →
Next: charge for an action
You already saw it in door.ts: price: '$0.50' is one line. The quote pins the price, the charge runs on your own Stripe with auto-refund on failure, and Bubblio takes no cut. Paid actions →