Guides

Keys & identity

A managed agent key (bak_…) lets an agent act as one of your signed-in users at your door. The custody model splits the secret three ways so no party can act alone — and the identity inside it is a token only your server could have signed.

Custody, split three ways

PartyHoldsCan't do
Your serverSigns the user's identity once at mint — a JWT (ctx, default 90-day) signed with a secret derived from your Bubblio API key.Never sees the raw key again; never stores it.
BubblioThe key hashed, the ctx encrypted beside it.Can neither verify nor forge the identity — it stores only a hash of your API key, so it cannot derive the signing secret.
The agentThe raw bak_ key, shown exactly once at mint.Can't read or alter the identity riding with it; can't outlive revocation.

On a keyed confirm, Bubblio decrypts the ctx and delivers it inside the HMAC-signed callback body as body.ctx — never as URL material (a query string lands in access logs, proxies, error trackers, and Referers, none of which the HMAC covers). Your createBubblioToolRoute resolves the user argument from it with zero new code. The raw key never travels to your servers; the signed body's agent block carries only { tier: 'keyed', keyId }.

DoctrineThe ctx carries identity only — who the agent acts as. Spend authority and mandate state live exclusively in Bubblio's server-side ledger. A leaked ctx must never be worth money — if a key path becomes spend-linked, mint its ctx with a short expiresInSeconds (hours, not months).

The one-route backend

The quickstart's createAgentKeyRoute is the whole thing — your session lookup is the only integration point:

  • GET → the signed-in user's keys — labels and display metadata, never key material.
  • POST { label? }{ key, id } — the raw key appears once, here. Show it; don't store it.
  • POST { grant } → the phone-connect path — answers { ok, connected, id }, never the key.
  • DELETE { id } → revoke, scoped to the user's own keys. Revoked keys refuse from that moment — in-flight quotes made with the key refuse to confirm.

Everything getUser returns except hint is signed into the identity and becomes your handlers' user argument on every keyed confirm — keep it to an id and a few small fields. hint is display-only (a masked email works well).

Per-user key caps

app/api/agent-keys/route.tsts
const route = createAgentKeyRoute({
  bubblioApiKey: process.env.BUBBLIO_API_KEY!,
  maxActiveKeysPerUser: 5,   // enforced by Bubblio INSIDE the mint — race-proof
  getUser: async (req) => { /* your session lookup */ },
})
// At cap: 409 { code: 'key_cap_reached' } — revoke a key or raise the cap.
// Racing mints fail CLOSED (a spurious 409 a retry resolves, never an
// overshoot) — provided every mint for that user carries the cap; an uncapped
// mint from another code path commits outside the enforcement.

Rotating your Bubblio API key — the runbook

The ctx signing secret derives from your API key, so rotating the key rotates the secret — and without the window below, identity tokens minted under the old key stop verifying: agents can still quote, but keyed 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 window exists.

the N/N-1 verify windowts
export const POST = createBubblioToolRoute({
  bubblioApiKey: process.env.BUBBLIO_API_KEY!,                       // the NEW key
  previousBubblioApiKeys: [process.env.BUBBLIO_PREVIOUS_API_KEY!],   // the OLD key
  tools: { …door.handlers },
})
// Verification tries the new key's derived secret first, then each previous
// one — keys minted before the rotation keep resolving their user. The old key
// is used only locally, only to derive the verification secret; it is never
// sent anywhere, so this works even after Bubblio has revoked it.
  1. Mint the new key in the dashboard; set BUBBLIO_API_KEY to it and BUBBLIO_PREVIOUS_API_KEY to the old one; deploy. 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 in use — the natural horizon is your ctx lifetime (default 90 days).
  4. Remove previousBubblioApiKeys and redeploy. 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): re-sign the stored ctx of existing keys under the new secret. A window shorter than your ctx lifetime means pre-rotation users re-mint through your route once it closes.

Advanced: custom key custody

custom custody handlersts
// Custom custody (advanced): you mint, store, and validate keys yourself.
// The visiting agent's raw key arrives inside the HMAC-signed body as the
// optional THIRD handler argument:
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!)  // YOUR check, YOUR database
  if (!account) return { error: 'unknown agent key' }         // → the confirm fails honestly
  return db.orders.place(account.userId, args)
},

Three rules keep this path 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; and never trust tier alone — the HMAC proves the body came through Bubblio unmodified, but a supplied key's validity is exclusively your call.