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):
| Control | What it means |
|---|---|
auto | No ceremony beyond a keyed agent's first visit (the connect tap). The default. |
mandate | Asks 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. |
always | A human approves every single run on their phone — right for anything scary or expensive. Standing mandates never cover it. |
The needs_approval flow
// 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_scriptto its human verbatim — the link must stay tappable — then keeps callingconfirmwithwait_seconds(≤ 25): it long-polls asawaiting_humanuntil 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/approvalssigns 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:
// 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 — 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)
}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).
labelrides alongsidegrantin the same POST — it names the key in your user's list, so phone-connected keys don't all read “unlabeled key”.location.searchis deliberate. In Next.js,useSearchParams()demands a Suspense boundary at build time; readingwindow.locationin 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.