Guides

Consent & phone connect

Agents act; humans consent. When an action needs a human, the quote turns into a phone page where the exact terms — action, arguments, price — are approved with a passkey. And when an agent needs a key it doesn't have, the same phone tap can sign the human in on your site and mint one, with nobody ever seeing or copying a credential.

Who approves each run: your per-action control

Beyond the open/keyed policy, every action carries an approval control — yours alone, set in the dashboard next to the policy switch. Your code can suggest one (approvalSuggestion, shown as a hint, never applied by a deploy):

ControlWhat it means
autoNo ceremony beyond a keyed agent's first visit (the connect tap). The default.
mandateAsks the human unless their standing mandate covers the run — under its per-action cap, inside its daily budget. Callers without an agent key always ask.
alwaysA human approves every single run on their phone — right for anything scary or expensive. Standing mandates never cover it.

The needs_approval flow

a needs_approval quotejson
// A quote that needs a human answers (a priced quote always carries the
// door's payment profile beside the pinned price):
{
  "quote_id": "q_e37f09c41b8d4a52b6a1c07d9e52f8a3",
  "status": "needs_approval",
  "tool": "generate_image",
  "args": { "prompt": "a lighthouse at night" },
  "price": { "amount_cents": 50, "currency": "usd", "display": "$0.50" },
  "payment": { "rails": ["payment_link"], "merchant": "Stylica" },
  "approval": {
    "url": "https://bubblio.dev/a/apr_51c0…",   // the phone page
    "code": "HV-38K",                            // the binding code
    "expires_at": "…"                            // ~30-minute approval window
  },
  "relay_script": "… — relay this to your human EXACTLY as given",
  …
}
  • The agent relays relay_script to its human verbatim — the link must stay tappable — then keeps calling confirm with wait_seconds (≤ 25): it long-polls as awaiting_human until the decision lands, and polling never consumes the quote.
  • One approval authorizes exactly one execution. Duplicate quotes for the same call join the pending approval (the consent-fatigue valve), but the first executing confirm consumes it — one tap can never authorize N runs.
  • Declines are final, and three declines in 24 hours cool that caller down at that door.

The approval page

The link opens bubblio.dev/a/{id} on the human's phone. It renders the server's facts, never the agent's claims: your platform's name and verified callback origin, the pinned action and arguments, the pinned price where there is one, the agent's identity — labeled verified when Web Bot Auth cryptographically proved it, or presented, unverified when it's just a claim — and a short binding code that must match what the agent showed.

  • GETs write nothing. Link scanners and chat prefetchers open links; approval acts only on POST after a WebAuthn ceremony.
  • The challenge is the terms. The passkey signs the SHA-256 of the canonical { platform, action, args_hash, amount } — every stored assertion cryptographically covers exactly what was approved.
  • The first Face ID is enrollment. One ceremony approves and mints the consent passkey; no account is ever created.
  • The kill-switch works without the agent. bubblio.dev/approvals signs in with one passkey assertion and lists everything this passkey granted — connections, receipts, standing mandates — with one-tap revocation, effective at the very next quote and confirm.

Phone connect: keys without the copy-paste

When an agent hits a keyed action without a key, the door can route its human through your own login page instead of refusing: the human taps the relayed link, signs in on your site, 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, with store_this_key: true. Nobody sees, copies, or pastes a key. Two additions on top of the quickstart's key route:

app/api/bubblio/tools/route.ts — declare your login pagets
// One line on the sync you already have — your phone-login page.
// Path-only by design: it resolves against callbackUrl's origin,
// never off your site.
sync: {
  door,
  callbackUrl: `${process.env.SITE_URL}/api/bubblio/tools`,
  connectPath: '/connect-agent',
}
app/connect-agent/page.tsx — forward the grantts
// app/connect-agent/page.tsx — the load-bearing pattern.
// Stash the grant FIRST: most OAuth round-trips drop the query string.
const GRANT_STASH_KEY = 'bubblio_connect_grant'

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)     // 201 { ok, connected: true, id }
  if (res.ok && data?.connected) sessionStorage.removeItem(GRANT_STASH_KEY)
  // show "Connected — hop back to the agent" (or data?.error with a retry button)
}
Will your login redirect keep the query string?Almost certainly not — most OAuth integrations rebuild their return URL from pathname alone, silently dropping ?bubblio_grant=… on the round-trip. (The first live integration hit exactly this.) The fix is the stash above: sessionStorage survives the hop to the provider and back in the same tab. Clear it after a successful forward so a refresh doesn't retry a spent handle.

Field notes

  • Assume the human arrives signed out. Render your auth UI in place on the connect page — a redirect to a login route strands the grant unless every return URL keeps the query string (the trap above).
  • label rides alongside grant in the same POST — it names the key in your user's list, so phone-connected keys don't all read “unlabeled key”.
  • location.search is deliberate. In Next.js, useSearchParams() demands a Suspense boundary at build time; reading window.location in a client effect skips the ceremony.
  • When the forward fails, the route revokes the just-minted key (best-effort — no credential ends up owned by nobody) and answers { error }. Render it with a retry button: transient failures retry cleanly with the same handle; a spent or expired grant keeps failing — the human asks the agent for a fresh link.
  • Custom wrappers: grants are not plain mints. If you wrap the key route's POST, 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. Detect grant requests with request.clone() before the SDK consumes the body.
  • Known limitation, by design for now: an MCP host that can't persist the delivered key re-runs the connect flow each session — one more tap for the human, never a broken action.