npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@bubblio/door

v1.0.1

Published

Agent Door SDK for Bubblio — turn any platform into an MCP endpoint AI agents can discover, act through, and pay on, with a single signed callback route.

Readme

@bubblio/door

The Agent Door SDK for Bubblio — turn any platform into an entrance AI agents can visit: free grounded Q&A, plus opt-in actions that run through quote→confirm exactly once, authenticated by managed agent keys your users mint on your platform, with an optional charge lane on your own Stripe account.

One backend route wires it up: verify the signed callback, resolve the calling user, dispatch to your handler, return JSON.

Guides: the docs · the quickstart · live doors at bubblio.dev/agents · a full reference integration: examples/nextjs-door

Formerly published as @bubblio/server. 1.0.0 dropped the customer-service session SDK (createBubblioSession and its widget-bootstrap types) — Bubblio no longer ships that product. Every door export below is unchanged from @bubblio/server; only the package name and version moved. See the CHANGELOG for the full rename note and history.

npm install @bubblio/door

Quickstart

Declare your actions in code, next to their handlers, and wire one callback route:

// lib/bubblio/door.ts
import { defineDoorActions } from '@bubblio/door'

export const door = defineDoorActions({
  actions: {
    check_credits: {
      description: 'Get the current credit balance',
      policySuggestion: 'keyed',
      handler: async (_args, user) => ({ credits: await getCredits(user!.id) }),
    },
  },
})
// app/api/bubblio/tools/route.ts — the entire callback backend
import { createBubblioToolRoute } from '@bubblio/door'
import { door } from '@/lib/bubblio/door'

export const POST = createBubblioToolRoute({
  bubblioApiKey: process.env.BUBBLIO_API_KEY!,
  tools: { ...door.handlers },
  // Build-safe first-request sync — never at module scope, once per process, retried on
  // failure. See "Code-declared actions" below.
  sync: { door, callbackUrl: `${process.env.SITE_URL}/api/bubblio/tools` },
})

Deploy. The first request syncs your action shapes to Bubblio — every synced action arrives hidden; only you, in the dashboard, decide which ones open to agents. A sync can never open capability on its own.


Receiving callbacks

When a visiting agent confirms an action, Bubblio POSTs to your callback route server-to-server. Verify the HMAC signature, resolve the per-user context, dispatch, return JSON — or skip all of that and use createBubblioToolRoute (below).

// app/api/bubblio/tools/route.ts, hand-rolled
import { verifyBubblioWebhook, userContextFromRequest, deriveUserContextSecret } from '@bubblio/door'
import { db } from '@/lib/db'

type AppUser = { id: string; email: string }
type Callback = { tool: string; args: Record<string, unknown> }

export async function POST(req: Request) {
  const raw = await req.text()

  // Requires the replay-protected pair (X-Bubblio-Signature-V2 + X-Bubblio-Timestamp),
  // verifies it against the raw bytes, and returns the parsed body — or null.
  const body = await verifyBubblioWebhook<Callback>(req, raw, process.env.BUBBLIO_WEBHOOK_SECRET!)
  if (!body) return new Response('invalid signature', { status: 401 })

  const user = await userContextFromRequest<AppUser>(
    deriveUserContextSecret(process.env.BUBBLIO_API_KEY!), // or your own secret if you set one
    req,
    body, // lets managed-key confirms resolve from the signed body.ctx (0.10+)
  )
  const { tool, args } = body

  if (tool === 'check_credits') return Response.json({ credits: await db.credits.get(user!.id) })

  return new Response('unknown tool', { status: 400 })
}

verifyBubblioWebhook refuses a request that carries only the pre-0.9 body-only X-Bubblio-Signature: that signature has no timestamp, so a captured callback replays against your endpoint forever. Pass allowLegacySignature: true only as a temporary escape hatch against a self-hosted platform older than 0.8.

userContextFromRequest reads the user-context JWT — from body.ctx inside the HMAC-signed body when you pass the verified body as the third argument (where managed-key confirms deliver it), falling back to the legacy ?ctx query param. By default the JWT is signed with a secret derived from your API key (deriveUserContextSecret) — both your routes already hold the key, so there is nothing extra to generate, and since Bubblio only stores a hash of your key, the platform can't derive it either.


Zero-boilerplate: createBubblioToolRoute

Don't want to wire HMAC + user-context by hand? One call does all of it — and pulls your webhook secret from the API key you already have, so there's no second secret to copy:

// app/api/bubblio/tools/route.ts — the entire callback backend
import { createBubblioToolRoute } from '@bubblio/door'

export const POST = createBubblioToolRoute({
  bubblioApiKey: process.env.BUBBLIO_API_KEY!,
  // per-user context verifies automatically via the API key — set callbackSecret only to override
  tools: {
    check_credits: (args, user) => db.credits.get(user!.id),
  },
})

It verifies the signature (auto-fetching + caching your secret, refreshing once after a rotation), extracts the per-user context, dispatches to the matching handler, and returns JSON. Returns a standard (Request) => Promise<Response>, so it drops into Next.js App Router, Cloudflare Workers, Deno, etc.

Note: the auto-fetched secret is your per-customer webhook secret, active by default on new accounts. If your account predates per-customer secrets, Bubblio still signs your callbacks with the legacy global secret — activate or rotate your secret once in dashboard → Settings → Webhook signing secret, and the auto-fetch verifies from then on.


Managed agent keys — one route + one button

A user clicks "Connect your AI agent" on your site, receives a bak_… key once, and pastes it into their agent. Bubblio stores the key hashed, next to an encrypted identity token that only your server could have signed (a long-lived signUserContext JWT — Bubblio can neither read into being nor forge it, since it only stores a hash of your API key). When the agent later confirms an action, Bubblio delivers that identity inside the HMAC-signed callback body as body.ctx (and, for a deprecation window, also as the legacy ?ctx=… query param for older SDKs) — so your existing createBubblioToolRoute resolves the user argument with zero new code. The raw key never travels to your servers; the signed body's agent block carries only { tier: 'keyed', keyId }.

The whole backend is one route:

// app/api/agent-keys/route.ts — mint, list, revoke; ~10 lines
import { createAgentKeyRoute } from '@bubblio/door'
import { getSession } from '@/lib/auth'

const route = createAgentKeyRoute({
  bubblioApiKey: process.env.BUBBLIO_API_KEY!,
  getUser: async (req) => {
    const session = await getSession(req)          // YOUR session lookup — the only integration point
    if (!session) return null                      // → 401
    return { id: session.user.id, hint: maskEmail(session.user.email) }
  },
})
export { route as GET, route as POST, route as DELETE }
  • GET → { keys: [...] } — the signed-in user's keys (labels and display metadata, never key material)
  • POST { label? } → { key, id } — mint; the raw key appears once, here — show it, don't store it
  • DELETE { id } → { ok: true } — revoke (scoped to the user's own keys)

Everything getUser returns except hint is signed into the identity token and becomes the user argument in your handlers when the agent acts — keep it to an id and a few small fields. hint is display-only (it appears next to the key in your user's list and the owner's Bubblio dashboard) — a masked email works well.

The frontend is a button that POSTs this route and shows the returned key one time. Lower-level pieces (mintAgentKey, listAgentKeys, revokeAgentKey) are exported for custom flows.

Phone connect — no copy-paste, no walk to a PC

When an agent hits one of your keyed actions without a key, Bubblio can route its human through your own mobile login instead of refusing: the human taps the link the agent relayed, lands on your sign-in page, and your key route mints the bak_… key server-side and forwards it straight into Bubblio's connect grant — the agent receives it through its poll, exactly once. Nobody sees, copies, or pastes a key.

Two additions on top of the route above:

  1. Tell Bubblio where your login page lives — one line on your existing sync:
door.sync({ bubblioApiKey, callbackUrl, connectPath: '/connect-agent' })
  1. On that page, after your normal sign-in, forward the grant handle from the URL (~10 lines):
// wherever your login flow lands, e.g. app/connect-agent/page.tsx
const GRANT_STASH_KEY = 'bubblio_connect_grant'

// On arrival — BEFORE any sign-in redirect can eat the query string (see the box below).
const fromUrl = new URLSearchParams(location.search).get('bubblio_grant')
if (fromUrl) sessionStorage.setItem(GRANT_STASH_KEY, fromUrl)
const grant = fromUrl ?? sessionStorage.getItem(GRANT_STASH_KEY)

if (grant) {
  const res = await fetch('/api/agent-keys', {          // the createAgentKeyRoute POST
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ grant, label: 'Phone connect' }),
  })
  const data = await res.json().catch(() => null)
  if (res.ok && data?.connected) {                      // 201 { ok: true, connected: true, id }
    sessionStorage.removeItem(GRANT_STASH_KEY)          // spent — a refresh must not retry it
    // show "Connected — hop back to the Bubblio tab to approve", nothing else to do
  } else {
    // show data?.error with a retry button — see "when the forward fails" below
  }
}

createAgentKeyRoute recognizes the grant field and returns { ok, connected, id } — never the raw key. The human then approves on the Bubblio page with Face ID, and the waiting agent's poll delivers the key with store_this_key: true.

WILL YOUR LOGIN REDIRECT KEEP THE QUERY STRING? Almost certainly not — most OAuth integrations build their return URL from pathname alone, so the round-trip through the provider silently drops ?bubblio_grant=… and the page comes back with nothing to forward. The snippet above is the fix: stash the grant in sessionStorage the moment the page loads, before any sign-in redirect, and read it back on the return leg — it's the same tab, so sessionStorage survives the hop to the provider and back. Clear the stash after a successful forward so a refresh doesn't retry a spent handle.

When the forward fails: success is 201 { ok: true, connected: true, id }. On any forward failure the route revokes the just-minted key (best-effort — no live credential ends up owned by nobody) and answers with error JSON — { error } carrying the upstream HTTP status. Render error with a retry button: a transient failure retries cleanly with the same handle, while a spent or expired grant keeps failing — the human just asks the agent for a fresh link (the agent re-quotes).

Custom wrappers: grants are not plain mints. If you wrap createAgentKeyRoute's POST with your own logic (per-user key caps, compensating revokes, telemetry), pass grant requests through untouched after mint: by the time your wrapper sees the response, the key has already been forwarded into Bubblio's grant, so a post-mint compensating revoke would strand an approved connection with a dead credential. And detect grant requests with request.clone() before handing the request to the SDK — the route consumes the body.

Known limitation (by design, for now): an MCP host that can't persist the delivered key across sessions will re-run the connect flow each session — one more tap for the human, never a broken action.

Code-declared actions — shapes live next to handlers

Declare door actions in code, beside their implementations, and sync the shapes to Bubblio — no dashboard copy-paste drift:

// lib/bubblio/door.ts
import { defineDoorActions } from '@bubblio/door'

export const door = defineDoorActions({
  actions: {
    check_credits: {
      description: 'Get the current credit balance',
      policySuggestion: 'keyed',
      handler: async (_args, user) => ({ credits: await getCredits(user!.id) }),
    },
    generate_image: {
      description: 'Generate an image from a prompt',
      parameters: [{ name: 'prompt', type: 'string', description: 'What to generate', required: true }],
      policySuggestion: 'keyed',
      timeoutSeconds: 15,
      handler: async (args, user) => generateImage(user!.id, String(args.prompt)),
    },
  },
})
// app/api/bubblio/tools/route.ts — door handlers merge into your callback route
import { createBubblioToolRoute } from '@bubblio/door'
import { door } from '@/lib/bubblio/door'

export const POST = createBubblioToolRoute({
  bubblioApiKey: process.env.BUBBLIO_API_KEY!,
  tools: { ...door.handlers },
  // First-request sync, wired for you: build-safe (never at module scope — `next build`
  // evaluates route modules), once per process, in-flight-deduped, retried on failure.
  sync: { door, callbackUrl: `${process.env.SITE_URL}/api/bubblio/tools` },
})

That one sync: line replaces a hand-rolled syncOnce() (and the next build footgun that comes with getting it wrong: a module-scope door.sync(...) fires inside every build, against build-time env, on a machine that may have no network).

Prefer explicit control? Skip the sync option and call door.sync({ bubblioApiKey, callbackUrl }) from a deploy script or admin endpoint instead, awaiting the result — { synced, orphaned, unchanged, pendingDecision, warnings }. door.sync is unchanged.

What sync does (and deliberately doesn't do):

  • Shapes only, never capability. Synced actions always arrive with policy off; your policySuggestion is shown in the dashboard as a hint, never applied. Only the owner flips an action live — which is why sync may run with just your API key.
  • Idempotent. Unchanged shapes are skipped server-side; run it on every cold start.
  • Orphans, not deletions. An action you stop declaring is flagged "no longer in code" and stops being served to agents — its policy and history survive, and re-declaring the name revives it.
  • Warnings, not failures. Destructive-looking names sync anyway (machine callers shouldn't 400), with the suggestion dropped and a warning logged — classify them consciously in the dashboard.
  • A decision, not a surprise. A sync that ships new actions emails the door's owner and prints [bubblio] N agent-door action(s) awaiting your decision → … in your deploy log — each stays hidden until the owner allows it (or keeps it hidden) in the dashboard, and a declined suggestion stays declined across future deploys.

Paid actions — price is one line, payments are one config

Charge for an action by declaring its price beside the handler, and give the callback route your Stripe secret key (it never goes to Bubblio — all charges run on your own Stripe account; no money ever moves through Bubblio):

export const door = defineDoorActions({
  actions: {
    generate_image: {
      description: 'Generate an image from a prompt',
      price: '$0.40',                    // or { amount_cents: 40, currency: 'usd' }
      approvalSuggestion: 'mandate',     // silent spend under a human-granted standing mandate
      handler: async (args, user) => generateImage(user!.id, String(args.prompt)),
    },
  },
})

export const POST = createBubblioToolRoute({
  bubblioApiKey: process.env.BUBBLIO_API_KEY!,
  payments: { stripeSecretKey: process.env.STRIPE_SECRET_KEY! }, // charge-wrap: charge → handler → auto-refund-on-throw
  tools: { ...door.handlers },
  sync: {
    door,
    callbackUrl: `${process.env.SITE_URL}/api/bubblio/tools`,
    // The door-level payment profile: which rails you accept, and (for wallet rails) whom to
    // grant to — your PUBLISHABLE key. Bubblio refuses sk_/rk_ values outright. (Distinct
    // from the route's own `payments` above: that one holds your SECRET key, locally.)
    payments: { rails: ['payment_link', 'stripe_spt'], stripePublishableKey: process.env.STRIPE_PK! },
  },
})

The guarantees, in one breath: the quote pins the price for its lifetime (an agent never pays more than quoted); the charged amount always comes from that pin — never from anything a caller sent; the quote id is the Stripe idempotency key, so a redelivered callback replays the same PaymentIntent instead of charging twice; a handler that throws after a successful charge is auto-refunded under quoteId + ':refund'; and the payment token is transit-only — it reaches the one paymentIntents.create call and is never stored, logged, echoed, or handed to your handler (meta.payment is the settled summary).

Two rails ship today:

  • payment_link — the human-pays handoff. A priced quote carries a Stripe Checkout URL minted on your account through your callback route; the agent relays it, the human pays on their phone (Stripe handles SCA), and your checkout webhook resumes the quote:

    // your Stripe webhook route
    if (event.type === 'checkout.session.completed') {
      await completeBubblioCheckout(event.data.object, {
        bubblioApiKey: process.env.BUBBLIO_API_KEY!,
        payments: { stripeSecretKey: process.env.STRIPE_SECRET_KEY! },
      })
    }

    Non-Bubblio sessions are ignored (safe on a shared webhook). A payment Bubblio refuses to settle — the quote expired, already executed, or already settled under another payment — is auto-refunded on your Stripe under quoteId + ':refund:' + sessionId: money that bought nothing never strands.

  • stripe_spt — the autonomy rail: an amount-capped Shared Payment Token a wallet grants against your publishable key, charged at confirm through the charge-wrap. Bubblio only lets it spend silently (no human tap on that quote) under a live standing mandate; give paid actions approvalSuggestion: 'mandate' so owners set the matching control.

Member parity — resolve what a specific caller pays at quote time, so a member/loyalty tier can discount (never surcharge) the listed price, or mark a run fully covered:

generate_image: {
  price: '$0.40',
  priceFor: async (args, user, meta) => {
    if (user && (await isPro(user.id))) return { covered: true, reason: 'pro plan' }
    return null // no opinion — the listed price stands
  },
  handler: async (args, user) => generateImage(user!.id, String(args.prompt)),
},

Every ambiguous outcome — no resolver, a throw, a timeout, a malformed answer — resolves to the listed price; nothing on this path can fail toward free. $0 only ever happens as an explicit covered: true.

Long-running paid actions: pushDoorResult carries the payment summary with the late result, so the upgraded receipt still shows the charge. And if the connection to Stripe dies mid-charge, the route retries once under the same idempotency key and otherwise answers "outcome unknown" — it will never claim "nothing was charged" without knowing, and never invites a re-quote that could double-charge; the PaymentIntent (if it exists) carries bubblio_quote metadata so you can find and settle it from your dashboard.

Advanced: custom key custody

If you'd rather mint, store, and validate agent keys yourself, the passthrough remains: the visiting agent's raw key arrives inside the HMAC-signed body and surfaces as the optional third handler argument:

export const POST = createBubblioToolRoute({
  bubblioApiKey: process.env.BUBBLIO_API_KEY!,
  tools: {
    // Managed keys:  meta.agent = { tier: 'keyed', keyId }  (identity arrives via body.ctx → `user`)
    // Custom custody: meta.agent = { tier: 'keyed', key }   (raw key — YOUR check, YOUR database)
    place_order: async (args, user, meta) => {
      if (meta.agent?.tier !== 'keyed') return { error: 'sign in required' }
      const account = await db.agentKeys.verify(meta.agent.key!)
      if (!account) return { error: 'unknown agent key' }      // → the confirm fails honestly
      return db.orders.place(account.userId, args)
    },
  },
})

Three rules keep this honest: you mint the keys, you validate the keys (Bubblio stores yours encrypted between quote and confirm and passes them through verbatim — it never validates them and never could); treat meta.agent.key like a password (any non-2xx fails the confirm honestly on the agent's side); never trust tier alone — the HMAC proves the body came through Bubblio unmodified, but a supplied key's validity is exclusively your call. Existing two-argument handlers keep working; meta.agent is undefined on callbacks with no key at all.

Rotating your Bubblio API key — the runbook

The managed-key identity token is signed with the secret derived from your Bubblio API key (deriveUserContextSecret) and lives ~90 days by default. So rotating the bbl_ key rotates the derived secret — and without the window below, identity tokens minted under the old key stop verifying: agents can still quote, but confirms resolve a null user. That failure is silent — a handler that treats null as guest keeps answering, just downgraded — which is exactly why the rotation window exists.

The N/N-1 verify window. Deploy the rotation with both generations configured:

export const POST = createBubblioToolRoute({
  bubblioApiKey: process.env.BUBBLIO_API_KEY!,            // the NEW key
  previousBubblioApiKeys: [process.env.BUBBLIO_PREVIOUS_API_KEY!], // the OLD key
  tools: { … },
})

Verification tries the new key's derived secret first, then each previous one — so managed keys minted before the rotation keep resolving their user, and keys minted after (your route signs with the new key automatically) verify on the first try. The old key is used only locally, only to derive the verification secret — it is never sent anywhere, so it works even after Bubblio has revoked it. The window widens verification, never authentication: a token signed under neither generation still resolves null.

The runbook, in order:

  1. Mint the new key in the dashboard; set BUBBLIO_API_KEY to it and BUBBLIO_PREVIOUS_API_KEY to the old one; deploy with the config above. Nothing breaks at any instant of this step.
  2. Revoke the old key in the dashboard whenever you like — the window doesn't need it live.
  3. Leave the window up while pre-rotation identity tokens are still in use. The natural horizon is your ctx lifetime (default 90 days): by then every surviving key's ctx has expired anyway and its user re-minted through your route.
  4. Remove previousBubblioApiKeys and redeploy. Done — never keep it around out of habit; an eternal N-1 secret quietly doubles what a leaked old key is worth.

What this deliberately does not do (yet): there is no server-assisted re-mint — no way to re-sign the stored ctx of existing bak_ keys under the new secret, so a rotation window shorter than your ctx lifetime means users whose keys were minted before the rotation must re-mint through your route once the window closes.


The two secrets (both automatic — your API key is the only credential)

Bubblio's security model uses two different secrets for two different jobs — and neither requires manual setup. Your BUBBLIO_API_KEY is the only thing you configure.

| | Webhook signing secret | Per-user context secret | |---|---|---| | Proves | "this callback is really from Bubblio" (authenticity) | "which of your users this key acts as" (identity) | | Owned by | Bubblio — signs every callback with it | You — signs the user-context token (body.ctx / legacy ?ctx=) | | Setup | none — createBubblioToolRoute auto-fetches it from your API key; first fetch provisions and activates your account's secret, so the platform signs with exactly what you verify against | none — derived from your API key on both sides (deriveUserContextSecret); pass an explicit callbackSecret only if you want to rotate it independently of the key | | Used by | verifyBubblioWebhook(req, raw, …) (or automatically inside createBubblioToolRoute) | mintAgentKey / createAgentKeyRoute → verified automatically inside createBubblioToolRoute |

Neither secret substitutes for the other: the webhook secret stops anyone but Bubblio from triggering your handlers; the context secret stops a caller from claiming to be a different user. Both hold with zero configuration — and Bubblio can forge neither, since it only ever stores a hash of your key.


Deploying to Vercel — set SITE_URL

Vercel exposes two URLs to your function: VERCEL_URL (deployment-specific, e.g. myapp-kbcs0lqyh-team.vercel.app) and VERCEL_PROJECT_PRODUCTION_URL (the production alias). By default, deployment-specific URLs sit behind Vercel Deployment Protection — server-to-server callbacks from Bubblio hit a 401 wall and never reach your function.

Fix: set SITE_URL in your Vercel project env vars to your public production domain:

SITE_URL=https://your-site.com

publicOrigin(request) resolves URLs in this order:

  1. SITE_URL — explicit override (recommended)
  2. VERCEL_PROJECT_PRODUCTION_URL — production alias
  3. VERCEL_URL — deployment URL (often gated)
  4. The request's host header

If you see "errored" tool calls in the Bubblio dashboard with ~40-90ms latency but zero invocations in your function logs, this is what's happening.


API

verifyBubblioWebhook<T>(request, rawBody, secret, opts?)
  → Promise<T | null>   // 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<Response>
  // 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<T>(secret, request, body?)
  → Promise<T | null>
  // Verifies the user-context JWT and returns the typed user. Reads `body.ctx` first when
  // you pass the HMAC-verified body (the signed-body placement for managed-key confirms),
  // else falls back to `?ctx` in the request URL.
  // null = guest token; throws on missing/invalid.

signUserContext<T>(secret, user, expiresInSeconds?)
verifyUserContext<T>(secret, token)
  // Lower-level JWT helpers (HS256). Use the helpers above unless you have a
  // custom transport. Accept a single secret or an array (rotation window).

deriveUserContextSecret(bubblioApiKey)
  → string
  // The per-user-context signing secret, derived from your API key — what mintAgentKey and
  // createBubblioToolRoute use by default, so there is no second secret to configure.

publicOrigin(request)
  → string
  // Resolves the callback origin. See "Deploying to Vercel" above.

createAgentKeyRoute({ bubblioApiKey, getUser, bubblioApiUrl?, expiresInSeconds?, maxActiveKeysPerUser? })
  → (request: Request) => Promise<Response>
  // 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: per-user active-key quota, enforced by Bubblio inside the
  //   mint (grant path included) — at cap: 409 { code: 'key_cap_reached' }.
  // See "Managed agent keys" above.

mintAgentKey({ bubblioApiKey, user, label?, userHint?, expiresInSeconds?, maxActiveKeysPerUser?, bubblioApiUrl? })
  → Promise<{ key, id }>   // the raw bak_ key — returned exactly once
listAgentKeys({ bubblioApiKey, externalUserId?, bubblioApiUrl? })
  → Promise<AgentKeyInfo[]>
revokeAgentKey({ bubblioApiKey, id, bubblioApiUrl? })
  → Promise<void>
  // Lower-level managed-key helpers behind createAgentKeyRoute.

defineDoorActions({ actions: { [name]: { description, parameters?, policySuggestion?,
                                         approvalSuggestion?, price?, priceFor?, 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. priceFor: resolve a member/loyalty price (or `covered: true`)
  //   at quote time — see "Paid actions" above; requires `price`.
  // 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: pass `sync: { door, callbackUrl, … }` to
  //   createBubblioToolRoute 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.

License

MIT. See LICENSE.