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:

terminalbash
npm install @bubblio/server   # + `stripe` only if you declare prices

The 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.tsts
// 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.tsts
// 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.tsts
// 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.

app/connect-agent/page.tsxtsx
'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&apos;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

.envbash
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 target

On 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 to any visitor. Next to the policy sits your approval control — auto, mandate, or always (a human approves every run on their phone). A deploy can never open an action; closing one never needs a deploy.
After every deploy that ships new actionsThe SDK prints one line with how many actions await your decision, and Bubblio emails you a summary — a new capability never slips by unnoticed. Declining (“keep hidden”) sticks across every future 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.

claude.ai — the hub connectortext
# 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.
Claude Codebash
# 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
raw HTTPbash
# 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 →