docs · the agent door

Let agents act on your platform.
On your terms, exactly once.

New to Bubblio? Start with the widget: Next.js guide → · script-tag guide →

The widget is the door for humans. The agent door is the other one — a stateless MCP endpoint (https://api.bubblio.dev/agent/{doorId}/mcp) into the same brain, knowledge, and receipts. Visiting agents ask questions for free; with the pieces on this page they can also act: run functions you declare in code, gated per action by you, through a quote→confirm machine that executes exactly once.

How it works

  1. 1
    Your code declares actions.
    defineDoorActions binds each action’s schema and its handler in one object — the shape lives next to the implementation, never in a dashboard form. On deploy, sync pushes the shapes to Bubblio. Shapes only: a sync can never open anything.
  2. 2
    You decide, per action, in the dashboard.
    Synced actions arrive hidden. The dashboard shows your code’s policySuggestion next to each one and waits for your decision: keep it hidden, open it to agents carrying a key, or open it to any agent. A deploy can never open an action; closing one never needs a deploy.
  3. 3
    Agents quote, confirm, and your handler runs.
    A visiting agent pins the exact call with quote, then executes it with confirm — exactly once, receipted, HMAC-signed to your callback route. For keyed actions the user’s identity arrives as the same signed ?ctx your widget tools already use, so your existing route resolves `user` with zero new code.

Step 1 — Declare actions next to their handlers

One file, e.g. lib/bubblio/door.ts. Each action is a schema and a handler in one object; policySuggestion is a hint the dashboard displays — it is never applied. Handlers get (args, user, meta), the same signature as every widget tool.

lib/bubblio/door.ts
// The shape and the handler live in ONE object — nothing to copy into a dashboard.import { defineDoorActions } from '@bubblio/server'export const door = defineDoorActions({actions: {check_credits: {description: 'Get the current credit balance',policySuggestion: 'keyed', // a HINT for the dashboard — never appliedhandler: 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)),},},})

Step 2 — Merge the handlers, sync on cold start

The door reuses the callback route you (may) already have for widget tools — spread door.handlers in, and fire door.sync at module scope. Sync is idempotent: unchanged shapes write nothing, actions you stop declaring are flagged “no longer in code” (never deleted), and new actions always land hidden, awaiting your decision in the dashboard.

app/api/bubblio/tools/route.ts
// app/api/bubblio/tools/route.ts — door handlers merge into your EXISTING callback route.import { createBubblioToolRoute } from '@bubblio/server'import { door } from '@/lib/bubblio/door'export const POST = createBubblioToolRoute({bubblioApiKey: process.env.BUBBLIO_API_KEY!,tools: { ...door.handlers /* , …your widget-session tools */ },})// Cold-start sync: idempotent, so module scope is fine — shapes stay fresh on every deploy.void door.sync({bubblioApiKey: process.env.BUBBLIO_API_KEY!,callbackUrl: `${process.env.SITE_URL}/api/bubblio/tools`,}).catch((err) => console.error('[bubblio] door sync failed:', err))

After a deploy that ships new actions, the SDK prints one line with how many are awaiting your decision, and Bubblio emails you a summary — so a new capability never slips by unnoticed. Destructive-looking names sync anyway (a deploy should never wedge on a heuristic) with the suggestion dropped and a warning logged.

Step 3 — Decide in the dashboard

Open the door on the Agent door page (one toggle — questions only until you say otherwise). Every synced action then waits for a decision:

hiddenThe default, forever, until you act. Invisible to agents; nothing can run.
needs a keyEvery agent sees it; only one carrying an agent key minted by a signed-in user on YOUR platform can run it (step 4).
openAny visiting agent can quote and confirm it, no key needed. Still capped, still receipted.

Accepting the code's suggestion is one click; declining (“keep hidden”) sticks — a declined action stays declined across every future deploy. Nothing runs until you decide, and closing an action takes effect immediately, no deploy involved.

Step 4 — Let users connect their agents (managed keys)

Keyed actions need identity. A user clicks “Connect your AI agent” on your site, gets a bak_… key once, and pastes it into their agent. The whole backend is one route — you supply only your session lookup:

app/api/agent-keys/route.ts
// app/api/agent-keys/route.ts — mint, list, revoke. getUser is the ONLY integration point.import { createAgentKeyRoute } from '@bubblio/server'import { getSession } from '@/lib/auth'const route = createAgentKeyRoute({bubblioApiKey: process.env.BUBBLIO_API_KEY!,getUser: async (req) => {const session = await getSession(req) // YOUR session lookupif (!session) return null // → 401return { id: session.user.id, hint: maskEmail(session.user.email) }},})export { route as GET, route as POST, route as DELETE }

GET lists the signed-in user's keys, POST mints one (the raw key appears once, there — show it, don't store it), DELETE revokes, scoped to the user's own keys. Everything getUser returns except hint is signed into the identity and becomes your handlers' user argument on every keyed confirm.

Test your door

Your door speaks standard MCP (Streamable HTTP, JSON responses) — any client works. The exact URLs, plus copy buttons, live on your Agent door page; every test visit shows up there as a real conversation.

terminal
# 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

Who holds what — the custody and authority doctrine

  • Code declares. The dashboard governs.Sync writes shapes; only the owner, logged into the dashboard, sets policy. A deploy can never open a capability, and closing one never needs a deploy. This is why sync may run with just an API key: a shape-only write can’t open anything.
  • Exactly once, or a replayed receipt.quote pins the exact tool and args and expires in 10 minutes; confirm consumes the quote with a winner-only update. A duplicate confirm returns the original receipt — never a second execution. Every confirm is receipted and rate-capped.
  • Bubblio holds the key, your server holds the identity.Managed bak_ keys are stored hashed; the raw key never travels to your servers (callbacks carry only { tier: ‘keyed’, keyId }). Identity is a JWT only your server could have signed — Bubblio stores it encrypted and can neither read it into being nor forge it, since it keeps only a hash of your API key. On confirm it arrives as the same signed ?ctx the widget uses.
  • Rotating your API key rotates the identity secret.Identity tokens are signed with a secret derived from your Bubblio API key (~90-day lifetime). After a rotation, keyed confirms resolve a null user until each user re-mints through your route — plan rotations, and tell agent-connected users.
  • Payments are a later rung.Actions run against your own backend. Bubblio never holds funds.
Open a door of your own.
The public directory of open doors — and the case for why they exist — lives at bubblio.dev/agents. Full API surface: the @bubblio/server README.
Request accessYour agent door →