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 and refreshIdentity. 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": "<your-tenant-id>", // AI Assistant's registered tenant claim
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("https://YOUR-PRODUCT-DOMAIN (set when you register the provider)")
.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 refreshIdentity() after login, logout, token rotation and every account switch — it reloads the correct history. Never keep a token or nonce in storage, cookies or state. Console → Integration renders the full embed snippet with your values.
5. 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.
6. Sign in inside the chat
The redirect above takes the visitor away and brings them back. A sign_in
page tool does it without leaving the conversation: the password 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. See Forms and sign-in inside the
chat.
7. 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 refreshIdentity(); 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 refreshIdentity(); account data is unavailable and the frame is anonymous.
[ ] Login again as the same account: call refreshIdentity(); that user's identified history returns.
[ ] Switch to a second account: call refreshIdentity(); it cannot see the first user's history or data.
Do not rely on a post-message-only identify() flow. Use refreshIdentity() after login,
logout, access-token/session rotation, and every account switch.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
refreshIdentity(): 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.