Guides
Recognize signed-in customers
Let the assistant trust who is signed in: publish your public key, have your API sign a short-lived proof, and wire getIdentity and refreshIdentity.
On this page
AI Assistant recognizes your signed-in customers without sharing an account database. Your API signs a short-lived proof for each one (a launch token — an ES256 JWT valid for 120 seconds with a one-time nonce), you publish the matching public key (JWKS) as the workspace's identity provider, and the widget calls getIdentity, identityChanged, and signedOut. Your mate then serves that customer's history and account tools — only theirs.
Two identities
- Your team signs in to the Console with their own accounts.
- Your customers never get a platform account. Each launch carries a short-lived proof your product signed; its subject is your unchanging customer id. The claims prove who is chatting.
1. Keys
Create an ES256 key pair. Publish the public key at https://yourdomain/.well-known/jwks.json with a kid; keep the private key on your server. ES256 is the reference algorithm; the allowed list is part of the registration.
2. Register the provider
Open Console → Identity — or call upsert_tenant_identity_provider — and enter:
| Field | Value |
|---|---|
| Issuer | your origin, for example https://yourdomain |
| JWKS URL | https://yourdomain/.well-known/jwks.json |
| Audience | busymate-ai |
| Workspace claim | tenant_id, equal to your workspace id |
| Subject claim | sub — the unchanging internal customer id |
| Max proof age | at most 120 seconds |
| Mint endpoint | the URL of the endpoint from step 3 |
Save the draft, run the checks, publish. An incomplete sign-in setup blocks the release.
3. The mint endpoint
Your API exposes one endpoint requiring your own signed-in session, returning a freshly signed proof for that customer:
import { SignJWT, importJWK } from "jose";
// POST https://YOUR-PRODUCT-DOMAIN/api/bmai/identity — requires YOUR OWN logged-in product session.
app.post("/api/bmai/identity", requireSession, async (req, res) => {
// Fresh on EVERY mint. Never persist the launch token/nonce in localStorage,
// sessionStorage, cookies, React state, or module state: every assistant
// launch consumes this pair exactly once.
const nonce = typeof req.body?.nonce === "string" ? req.body.nonce : "";
if (!/^[A-Za-z0-9_-]{32,200}$/.test(nonce)) return res.status(400).json({ error: "invalid_nonce" });
const key = await importJWK(JSON.parse(process.env.AI_LAUNCH_PRIVATE_JWK), "ES256");
const token = await new SignJWT({
tenant_id: process.env.BMAI_TENANT_ID, // the tenant id shown in Console → Identity
nonce, // equals the sibling field below
name: req.user.displayName, // optional low-sensitivity display claim
})
.setProtectedHeader({ alg: "ES256", kid: process.env.AI_LAUNCH_KEY_ID })
.setIssuer(process.env.BMAI_ISSUER) // the Issuer you registered in Console → Identity
.setAudience("busymate-ai")
.setSubject(req.user.id) // IMMUTABLE internal account id; never email/phone/session id
.setJti(crypto.randomUUID()) // one-time (replay-protected)
.setIssuedAt()
.setExpirationTime("120s") // <= registered max age (120s)
.sign(key);
res.set("Cache-Control", "no-store");
res.status(201).json({ token, nonce, expiresIn: 120 });
});- The nonce arrives from the widget, must match
^[A-Za-z0-9_-]{32,200}$, and is echoed back. - Claims:
iss,aud,sub, your workspace claim,nonce, a one-timejti,iat,expwithin the registered max age. - Respond
201with{ token, nonce, expiresIn }andCache-Control: no-store. Each pair is consumed exactly once.
4. Wire the widget
Define window.BusymateAI.getIdentity before the embed script loads. It returns a fresh { token, nonce } for a signed-in customer, null for a signed-out one. Call identityChanged() after login, token rotation and every account switch — it upgrades the live conversation in place, same thread. Call signedOut() on logout — it ends the session and clears the previous transcript. (refreshIdentity() still answers, as an older, heavier fallback that remounts instead of upgrading in place.) Never keep a token or nonce in storage, cookies or state. Console → Integration renders the full embed snippet with your values.
5. Pick your platform
Every platform is the same four messages over a different transport. The
integration kit at https://busymate.ai/sdk/v1/kit/ has a drop-in for each one,
a runnable sample beside it, and a README that names the four obligations in one
place.
| You are building | Copy | Signal in with | Signal out with | Guide |
|---|---|---|---|---|
| A website, any framework | kit/web/busymate-identity.js | identityChanged() | signedOut() | this page |
| Android (WebView) | sdk/v1/android/BusymateAI.kt | bridge.identityChanged() | bridge.signedOut() | In-app support |
| iOS (WKWebView) | sdk/v1/ios/BusymateAI.swift | bridge.identityChanged() | bridge.signedOut() | In-app support |
| Electron · Tauri · WebView2 | kit/desktop/preload.js | busymateDesktop.identityChanged() | busymateDesktop.signedOut() | Desktop |
| React Native | kit/react-native/busymateIdentity.js | bridge.identityChanged() | bridge.signedOut() | React Native |
| Flutter | kit/flutter/busymate_identity.dart | bridge.identityChanged() | bridge.signedOut() | Flutter |
| Your API | kit/backend/{node,php,python,go} | — | — | Backend signing |
Each platform guide ends with the same conformance checker this page uses, run against your own integration rather than a sample.
Your app's WebView loading your OWN page is a fifth shape, not a variant of the above. If your app loads a page you built — which then embeds the assistant — the assistant asks that page, not your app; your page has no session of its own, so only the first launch is ever answered. See Fix a signed-in customer who shows as a guest for the fix.
Three rules apply on every platform above, and missing any is silent:
- Register the bridge before the page loads. On the web the loader parks an ask it cannot answer and flushes it later; in an app, a handler installed after first paint depends on a bounded retry that eventually gives up.
- Mint fresh on every ask. A launch proof is single-use. A cached one is refused, which is why this failure reads as intermittent rather than broken.
- Signal both directions. A sign-in that is never signalled leaves a signed-in customer talking as a guest; a sign-out that is never signalled leaves their conversation in front of whoever is next at that device.
You never configure cookies for any of this. Identity arrives from you on each launch, so third-party cookie policy has no bearing on whether a customer is recognized.
6. Full-page open
For a hosted page instead of an embed, mint the same pair and open your address with the token and nonce in the URL fragment — never the query string, referrer or logs. The destination strips it before the exchange. The hosted-handoff snippet in Integration shows the exact form.
The redirect handoff your own login page must complete
Registering loginUrl is a second way to identify a visitor: when a signed-out visitor clicks Sign in on your standalone hosted page, the platform sends them to your loginUrl with return_to and a one-time bmai_nonce on the query string. Your login page must, on that same request:
- Complete (or already hold) your customer's normal sign-in.
- Mint the pair with the identical endpoint your
identityEndpointUrluses, passing the receivedbmai_nonce. - Redirect the browser to the exact
return_tovalue, with#bmai_token=<token>&bmai_nonce=<nonce>in the URL fragment — never the query string.
A page that ignores return_to/bmai_nonce — a plain login form that only redirects to your dashboard — signs the customer in for real while the chat receives no token and stays a guest, which from their side is indistinguishable from a broken sign-in. The platform cannot finish it for you: minting the token needs YOUR signing key. Console → Integration → Identity flags this as soon as loginUrl is registered and no hosted_web identified session has ever been recorded.
7. Sign in inside the chat
The redirect above takes the visitor away and brings them back. A sign_in tool
— a page tool, or one on your connected server — does it without leaving the
conversation: your own form is rendered in the thread as you returned it, the
credential reaches your backend and never the assistant, and on
{ signedIn: true } the widget calls your getIdentity again and re-mints the
session in place — the same thread, now identified. Implement it and that is
what a signed-out visitor gets; implement nothing and they get the redirect
above, which is why registering loginUrl matters even when you plan to add the
tool later. See Let your users sign in from the chat for
the tool, and Forms and sign-in inside the chat for the
card contract.
8. Sign in as a test account (machine sign-in)
Your mate can run the full diagnostics as one of your own customers — Console → Connections → Test account, or verify_tenant_private_tools — but only if it can sign in as that account without a browser. The endpoint in step 3 cannot do that: by design it requires the customer's own session, and a check is a server with no session and no origin. This section is the contract that closes the gap.
You publish nothing secret and we hold nothing of yours. The check presents a short-lived signed assertion; you verify it against our published keys with any standard JOSE library, and answer with the same launch token your step-3 endpoint already mints. There is no shared secret to install, store or rotate.
What we send
The check POSTs to the same identityEndpointUrl, with an Authorization: Bearer assertion instead of a customer session:
| Format | JWT (RFC 7519), signed ES256 (RFC 7518), kid in the header |
| Our keys | https://busymate.ai/.well-known/jwks.json (RFC 7517) |
iss | https://busymate.ai |
aud | your identity provider's issuer origin — an assertion minted for you is inert everywhere else |
sub | the login of the test account registered in your workspace |
purpose | test-account-sign-in — this token is valid at nothing else, and nothing else is valid here |
tenant_id | your registered Workspace claim value — the same one your own launch tokens carry |
nonce | the same nonce in the body; echo it back as always |
jti, iat, exp | one-time id; at most 60 seconds old |
⚠️ The one rule that matters
A valid assertion means "busymate.ai is asking, on behalf of the account named in sub". It does not mean "mint for whoever sub says". Check sub against your own list of review logins and refuse anything else. A tenant that mints for an arbitrary sub has handed us the ability to impersonate its customers — which is exactly what the launch-token design exists to prevent.
Add it to your endpoint
// Add this ONE branch to the endpoint from step 3. Everything else there
// stays exactly as it is: your customers' browsers keep using the session arm.
import { createRemoteJWKSet, jwtVerify } from "jose";
// Our published keys. Cache the key set — do not fetch it per request.
const BUSYMATE_JWKS = createRemoteJWKSet(
new URL("https://busymate.ai/.well-known/jwks.json"),
);
// The review logins YOUR product recognises. THIS LIST IS THE SECURITY BOUNDARY:
// a valid assertion means "busymate.ai is asking on behalf of <sub>", never
// "mint for whoever <sub> says". Anything not in here must be refused, or you
// have handed us the ability to impersonate your customers.
const REVIEW_LOGINS = new Set(["bmaireview@your-assistant.example"]);
app.post("/api/bmai/identity", async (req, res) => {
const auth = req.headers.authorization ?? "";
if (auth.startsWith("Bearer ")) {
let claims;
try {
({ payload: claims } = await jwtVerify(auth.slice(7), BUSYMATE_JWKS, {
issuer: "https://busymate.ai",
// YOUR provider's issuer origin. An assertion minted for someone else
// fails here, which is what audience binding is for.
audience: "https://YOUR-PRODUCT-DOMAIN",
algorithms: ["ES256"],
maxTokenAge: "60s",
}));
} catch {
return res.status(401).json({ signedIn: false, reason: "invalid_assertion" });
}
// Single-purpose: this token is valid for nothing else, and nothing else
// is valid here.
if (claims.purpose !== "test-account-sign-in") {
return res.status(403).json({ signedIn: false, reason: "wrong_purpose" });
}
if (typeof claims.sub !== "string" || !REVIEW_LOGINS.has(claims.sub)) {
return res.status(403).json({ signedIn: false, reason: "not_a_review_account" });
}
const user = await findUserByLogin(claims.sub); // YOUR lookup
if (!user) {
return res.status(403).json({ signedIn: false, reason: "not_a_review_account" });
}
// The SAME mint your browser arm already calls, with the SAME nonce rules.
return res.json(await mintLaunchToken(user, String(claims.nonce ?? req.body.nonce)));
}
// ── unchanged: your customers' own session ──────────────────────────────
const user = await currentUser(req);
if (!user) return res.status(401).json({ signedIn: false });
return res.json(await mintLaunchToken(user, req.body.nonce));
});Verification checklist
Run these before calling machine sign-in done.
GET https://busymate.ai/.well-known/jwks.jsonreturns our current signing key set. This is one optional arm of machine sign-in; a404here means that arm is not available yet, not that setup on your side is wrong — skip to "Or publish asign_intool instead" below and use that arm.- A request with no
Authorizationstill behaves exactly as before for your customers' browsers. This section adds an arm; it must not change the session arm. - A hand-made token signed with your own key is refused — you verify against our JWKS, not yours.
- An assertion whose
audis another origin is refused. - An assertion whose
subis a real customer who is not a registered review login is refused. This is the rule above; test it explicitly. - An assertion older than 60 seconds is refused.
- A valid assertion returns the identical
{ token, nonce, expiresIn }shape your browser arm returns, with the nonce echoed. - Console → Connections → Test account → Run full check reports
sign_ingreen and names the method.
Or publish a sign_in tool instead
If you already expose sign-in as a tool (see §7), you need none of the above: the check calls your own sign_in on one of your connected servers with the account's login and its password — or its code, if your product signs customers in with a one-time code and you have registered the account as One-time code (fixed review code). Either arm is enough; the Console names the one your workspace is missing.
9. Acceptance checklist
Setup progress proves configuration, not that the flow works. Run this before calling sign-in done.
Start with Console → Identity → "Test identified launch" on your provider row. It runs the real preflight: it fetches your JWKS through the outbound guard and checks a usable key exists for every algorithm you registered (ES256 needs an EC P-256 key), pushes a deliberately unsigned probe carrying your own issuer, audience and claims through the same verifier the live launch uses — which must refuse it — and reports whether the provider is in the current draft and in the published revision. A 404 JWKS URL, a key set with no matching key, a mistyped issuer or audience, or an enabled-but-unpublished provider each show up as a red arm with the exact reason, instead of as a customer whose session quietly degrades to anonymous. The same check decides the publish gate's identified-launch, so a red here blocks the release.
To prove the last step end to end, mint a real token with your own endpoint and paste it with its nonce: the test verifies that exact assertion against your registered provider and reports pass/fail with the claim names it checked. It never stores or echoes the token, the subject, or any claim value. test_tenant_identity_provider is the MCP twin and runs the identical check.
REQUIRED AUTH + HISTORY ACCEPTANCE — AI Assistant / your mate
Setup progress is configuration evidence; it does not prove this workflow.
[ ] Console -> Identity -> "Test identified launch" is GREEN on your provider row.
(It fetches your JWKS and checks a key exists for every algorithm you
registered, then pushes a deliberately unsigned probe through the same
verifier the live launch uses and requires it to be REFUSED, and confirms the
provider is in the draft AND the published revision. Do not eyeball the form.)
[ ] Signed out: getIdentity returns null and the assistant remains anonymous.
[ ] Login without reloading the host page: call identityChanged(); the frame becomes identified.
[ ] Every getIdentity call returns a different nonce AND JWT jti. No launch token or nonce is persisted.
[ ] The subject is the same immutable internal AI Assistant account id across sessions/devices — never email, phone, browser id, or session id.
[ ] Send an identified message; refresh the host page; the same conversation reappear.
[ ] That refresh produces no launch_replayed, invalid_token, or silent anonymous fallback.
[ ] Logout: call signedOut(); account data is unavailable and the frame is anonymous.
[ ] Login again as the same account: call identityChanged(); that user's identified history returns.
[ ] Switch to a second account: call identityChanged(); it cannot see the first user's history or data.
Do not rely on a post-message-only identify() flow. Use identityChanged() after login,
access-token/session rotation, and every account switch; use signedOut() after logout.10. Check your own integration
"My customer shows up as a guest" is the one symptom every mistake on this page
produces, and it names none of them. Two checks, one set of rules: the
Identity check grades the
lifecycle half against your own page (nothing uploaded, shapes recorded rather
than values), and the CLI grades the backend half a browser cannot read — JWKS,
claim shape, TTL, nonce echo, clock skew, your endpoint's origin policy. Each
exits 0 passed, 1 failed, 2 could not be observed — never a silent pass.
Fix a signed-in customer who shows as a guest
walks each symptom to its cause and the cell that proves it.
Your own workspace sees the identical picture without leaving the Console: Customers lists every recognized person with their platforms and last-seen state, and Identity health shows the same lifecycle cells this checker runs, per platform, with the exact missing step named against real traffic instead of a manual probe.
Pitfalls
- An email, phone number or session id as
sub. Use the unchanging internal id. - A token or nonce kept in
localStorage, a cookie or React state. - A proof older than the registered max age, or a missing
kid. - Answering
getIdentityfor a signed-out customer with anything butnull.
Verify
- Signed out: the assistant is a visitor session.
- Log in without reloading and call
identityChanged(): the frame is identified. - The same customer on a second device sees the same history.
- A second account cannot see the first's history or data.
Next
- Connect your MCP server as assistant tools — the tools that need this identity.
- In-app AI support for iOS and Android — the same proof through a native bridge.
- White-label SDK → Customer identity — the full contract.
Questions
Do you store my customers' accounts?
No. Each launch carries a proof your product signed; the subject is your id. Conversations are keyed to that subject inside your workspace.
Why does the proof expire in 120 seconds?
It is a launch proof, not a session. A fresh one is minted per launch and used once, so a leaked proof is useless within moments.
Which algorithm must I use?
ES256 is the reference. The algorithms your provider accepts are part of its registration, checked against your JWKS.
Can visitors still chat?
Yes, when guest access is on. Signed-out visitors get answers and guidance; account tools need a signed-in customer.