AI Assistant lets a customer sign in without leaving the conversation, with the account they already have with you. You implement one tool; your sign-in card opens in the thread the moment a question needs their account, the credentials go to your own site, and the same conversation carries on signed in. No platform account is created for them, and nothing about their login reaches the assistant.
Yours end to end
Sign-in and sign-up are your product's. The card, the login page and the account creation flow are the ones you already run — the form you return is drawn verbatim, not adapted to a template of ours. AI Assistant never stores a password or creates a customer account; it only verifies the short-lived proof your site signs afterwards.
What the assistant learns is an unchanging customer id and, if you allow it, a few display claims such as a name or plan. Everything else stays behind your API and is fetched per request.
1. Register your accounts as the identity provider
Open Console → End-user identity, or call upsert_tenant_identity_provider. Enter your issuer, JWKS URL, audience, workspace and subject claims, the maximum proof age and the mint endpoint described in Recognize signed-in customers. Add your customer login URL as well — the hosted chat and the silent connect in step 4 use it. Save, press Test identified launch, then publish.
The check is standards-only: an ES256 (or RS256) JWT verified against your published JWKS for issuer, audience, subject, workspace claim, the nonce the launch asked for, a one-time jti, age and expiry. A failed check names the claim, never the value.
typescript
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 });
});
Questions
Do my customers need an account with AI Assistant?
No. They sign in to your product. Your site signs a short-lived proof, the assistant verifies it against your public key, and the subject is your own customer id.
Where do sign-ups happen?
In your product, the way they do today. The card or the login page you return is yours, so a new customer creates an account with you and comes back to the chat signed in.
Can the assistant read the password?
No. A password field is accepted only on a card that submits into your own page. The value never enters a tool argument, the transcript or a log.
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 });
});
2. Put a sign-in card in the chat
Sign-in is a standard widget tool you implement. Register a tool named sign_in — as a page tool on any page where the chat is embedded, or on your connected MCP server — and return a form card from it:
javascript
BusymateAI.registerPageTools([
{
name: "sign_in",
description:
"Show the sign-in form in the chat when something needs the visitor's account.",
inputSchema: { type: "object", properties: {} },
// Showing a form changes nothing, so no confirmation stands in front of it.
annotations: { readOnlyHint: true },
execute: () => ({
$bmForm: 1,
title: "Sign in",
description: "You'll stay right here in this conversation.",
fields: [
{ name: "email", label: "Email", type: "email", required: true, autocomplete: "username" },
{ name: "password", label: "Password", type: "password", required: true, autocomplete: "current-password" },
],
submit: { label: "Sign in", tool: "sign_in_submit" },
cancel: { label: "Not now" },
}),
},
{
name: "sign_in_submit",
description: "Complete the sign-in the card collected.",
inputSchema: { type: "object", properties: {} },
annotations: { readOnlyHint: false },
async execute({ email, password }) {
const response = await fetch("/api/login", {
method: "POST",
credentials: "same-origin",
headers: { "content-type": "application/json", "x-csrf-token": window.CSRF_TOKEN },
body: JSON.stringify({ email, password }),
});
// `signedIn: true` is the ONE thing the chat reads. On it, the widget
// re-asks your page for an identity token and the SAME conversation
// continues signed in — no reload, nothing retyped.
if (!response.ok) return { signedIn: false, error: "invalid_credentials" };
return { signedIn: true };
},
},
]);
Whatever you return is rendered as you wrote it: your fields, your labels, your order, your wording, your links. Nothing of ours is added to your form, and there is no platform styling of your login to design around. Two companion names complete the set and both are optional — sign_up to create an account, sign_out to end the session — on the identical contract.
The fields are yours: email and password, an emailed code, or a single button that starts the sign-in you already offer. A password field is accepted only on a card that submits back into your own page, so the value goes from the input to your execute and nowhere else, and the settled card shows •••••. When your tool answers { signedIn: true }, the widget asks your page for a fresh proof through getIdentity and re-mints the session in place — same thread, now identified, the earlier question answered. The full field vocabulary and its limits are in Forms and sign-in inside the chat.
As many steps as your login has
A form may answer with a form: whatever your submit tool returns is drawn next, in the same card. A choice of Email or Phone, then your fields, a display-name field, a consent checkbox, links to your own terms and privacy, then a one-time code — each step posting to your own endpoint — is still the one sign_in tool and nothing new to wire.
Hold anything a step needs to remember in your own page, the way the snippet below keeps the contact it just sent a code to. The form has no hidden field, and one invented would simply be dropped.
No passwords? Use a one-time code
If you sign customers in with a code to a phone or an email, the card is two steps: ask where to send the code, then ask for the code.
javascript
// The contact the code was sent to. It lives HERE, in your page, for the
// one hop between the two cards: the form spec has no hidden-value field, and
// inventing one would simply be dropped by the parser.
let pendingContact = null;
BusymateAI.registerPageTools([
{
name: "sign_in",
description:
"Show the sign-in form in the chat when something needs the visitor's account.",
inputSchema: { type: "object", properties: {} },
// Showing a form changes nothing, so no confirmation stands in front of it.
annotations: { readOnlyHint: true },
execute: () => ({
$bmForm: 1,
title: "Sign in",
description: "We'll text or email you a one-time code. You'll stay right here.",
fields: [
{ name: "contact", label: "Phone or email", type: "text", required: true, autocomplete: "username" },
],
submit: { label: "Send me a code", tool: "sign_in_send_code" },
cancel: { label: "Not now" },
}),
},
{
name: "sign_in_send_code",
description: "Send the one-time code, then ask for it.",
inputSchema: { type: "object", properties: {} },
annotations: { readOnlyHint: false },
async execute({ contact }) {
const response = await fetch("/api/auth/otp/start", {
method: "POST",
credentials: "same-origin",
headers: { "content-type": "application/json", "x-csrf-token": window.CSRF_TOKEN },
body: JSON.stringify({ contact }),
});
if (!response.ok) return { signedIn: false, error: "could_not_send_code" };
pendingContact = contact;
// A form may answer with a form. This is the SECOND card, in the same
// conversation — the visitor never leaves and never retypes anything.
return {
$bmForm: 1,
title: "Enter your code",
description: "We sent a 6-digit code. It expires shortly.",
fields: [
// NOT `password`: a one-time code is not a stored credential, and
// `one-time-code` is what lets a phone offer the SMS it just got.
{ name: "code", label: "Code", type: "text", required: true, autocomplete: "one-time-code" },
],
submit: { label: "Sign in", tool: "sign_in_submit" },
cancel: { label: "Not now" },
};
},
},
{
name: "sign_in_submit",
description: "Complete the sign-in the card collected.",
inputSchema: { type: "object", properties: {} },
annotations: { readOnlyHint: false },
async execute({ code }) {
const response = await fetch("/api/auth/otp/verify", {
method: "POST",
credentials: "same-origin",
headers: { "content-type": "application/json", "x-csrf-token": window.CSRF_TOKEN },
body: JSON.stringify({ contact: pendingContact, code }),
});
// `signedIn: true` is the ONE thing the chat reads. On it, the widget
// re-asks your page for an identity token and the SAME conversation
// continues signed in — no reload, nothing retyped.
if (!response.ok) return { signedIn: false, error: "invalid_code" };
return { signedIn: true };
},
},
]);
Give the code field autocomplete: "one-time-code" and leave its type as text: a one-time code is not a stored credential, and text is what lets a phone offer the SMS it just received. Do not answer a sign-in request with a tool that returns an explanation of how to log in — the chat will say a card is coming and then read your customer a paragraph instead.
If you implement nothing
Both snippets above are starting points, not the contract — a tool of your own that returns the same form is exactly as good, and a login neither of them fits is a reason to write your own, not a reason to skip it.
If you register no tool at all, nothing is invented on your behalf. There is no card, and the assistant will not promise one: it answers with your real sign-in link, or sends the visitor to your login URL at the top level (step 3). Publish neither and it offers nothing rather than an errand with nowhere to go.
3. Know what happens where
Where the chat runs
What the Sign in control does
Embedded on your page, sign_in registered
The card opens in the thread. Nothing leaves the page.
Embedded on your page, no sign_in tool
Your page navigates to your login URL with return_to and a one-time bmai_nonce, then returns and hands the proof to the widget.
Your hosted chat address
The same redirect: to your login URL and back to the exact return_to, proof in the URL fragment.
Your iOS or Android app
The app receives the request over the native bridge and runs its own login.
No sign_in tool and no login URL published
Nothing is offered. The assistant does not announce a card it cannot show.
A credential is never typed into a frame that is not yours. The card submits into your page, and a redirect always happens at the top level, on your origin.
4. Connect a customer who is already signed in
If the customer already has a session with your product, the chat can inherit it without showing a form. On the hosted address and in the embed, the widget opens your login URL once per tab in a hidden frame with bmai_prompt=none — the prompt=none meaning every identity stack knows: answer only from an existing session, never render a form. Your login redirects straight back with the proof in the fragment, and the session is re-minted signed in. It works when:
your customer login URL is published on the current revision;
your login honours bmai_prompt=none — an existing session answers, a signed-out visitor is sent back with nothing;
the probe finishes within three seconds; it runs once per tab and fails silently, so the visitor simply stays a guest with the Sign in control still there.
This can only add an identity. It never blocks the chat, never redirects the visible page, never traps anyone in a login form, and is not attempted inside a native app, which passes identity over its own bridge.
5. Choose what a signed-in customer can do
Each connected tool carries an access level. Public tools answer anyone. Identified tools run only after the proof was verified and receive your customer id. Delegated tools also need Allow delegated customer account access on the provider and act through a signed actor token or a per-customer consent. Set the levels in Connect your MCP server as assistant tools. The sites at demo.busymate.ai each include a demo customer to sign in as: the same card, then orders, bookings or balances for that one person.
Verify
Signed out, ask something that needs an account: the card appears in the thread, not a link away.
Sign in from the card: the same conversation continues and your earlier question is answered.
Open the tool call's details: the password shows as •••••.
Sign in to your product in another tab, then open the hosted chat: it is signed in with no form shown.
A second customer cannot see the first one's history or account.
Nothing visible happens. The probe expires after three seconds, the visitor stays a guest and the Sign in control is still there.
What happens if I never add the sign_in tool?
There is no card, and the assistant says so plainly instead of promising one: it gives your real sign-in link, or sends the visitor to your login URL at the top level. Nothing of ours stands in for your login.
BusymateAI.registerPageTools([
{
name: "sign_in",
description:
"Show the sign-in form in the chat when something needs the visitor's account.",
inputSchema: { type: "object", properties: {} },
// Showing a form changes nothing, so no confirmation stands in front of it.
annotations: { readOnlyHint: true },
execute: () => ({
$bmForm: 1,
title: "Sign in",
description: "You'll stay right here in this conversation.",
fields: [
{ name: "email", label: "Email", type: "email", required: true, autocomplete: "username" },
{ name: "password", label: "Password", type: "password", required: true, autocomplete: "current-password" },
],
submit: { label: "Sign in", tool: "sign_in_submit" },
cancel: { label: "Not now" },
}),
},
{
name: "sign_in_submit",
description: "Complete the sign-in the card collected.",
inputSchema: { type: "object", properties: {} },
annotations: { readOnlyHint: false },
async execute({ email, password }) {
const response = await fetch("/api/login", {
method: "POST",
credentials: "same-origin",
headers: { "content-type": "application/json", "x-csrf-token": window.CSRF_TOKEN },
body: JSON.stringify({ email, password }),
});
// `signedIn: true` is the ONE thing the chat reads. On it, the widget
// re-asks your page for an identity token and the SAME conversation
// continues signed in — no reload, nothing retyped.
if (!response.ok) return { signedIn: false, error: "invalid_credentials" };
return { signedIn: true };
},
},
]);
// The contact the code was sent to. It lives HERE, in your page, for the
// one hop between the two cards: the form spec has no hidden-value field, and
// inventing one would simply be dropped by the parser.
let pendingContact = null;
BusymateAI.registerPageTools([
{
name: "sign_in",
description:
"Show the sign-in form in the chat when something needs the visitor's account.",
inputSchema: { type: "object", properties: {} },
// Showing a form changes nothing, so no confirmation stands in front of it.
annotations: { readOnlyHint: true },
execute: () => ({
$bmForm: 1,
title: "Sign in",
description: "We'll text or email you a one-time code. You'll stay right here.",
fields: [
{ name: "contact", label: "Phone or email", type: "text", required: true, autocomplete: "username" },
],
submit: { label: "Send me a code", tool: "sign_in_send_code" },
cancel: { label: "Not now" },
}),
},
{
name: "sign_in_send_code",
description: "Send the one-time code, then ask for it.",
inputSchema: { type: "object", properties: {} },
annotations: { readOnlyHint: false },
async execute({ contact }) {
const response = await fetch("/api/auth/otp/start", {
method: "POST",
credentials: "same-origin",
headers: { "content-type": "application/json", "x-csrf-token": window.CSRF_TOKEN },
body: JSON.stringify({ contact }),
});
if (!response.ok) return { signedIn: false, error: "could_not_send_code" };
pendingContact = contact;
// A form may answer with a form. This is the SECOND card, in the same
// conversation — the visitor never leaves and never retypes anything.
return {
$bmForm: 1,
title: "Enter your code",
description: "We sent a 6-digit code. It expires shortly.",
fields: [
// NOT `password`: a one-time code is not a stored credential, and
// `one-time-code` is what lets a phone offer the SMS it just got.
{ name: "code", label: "Code", type: "text", required: true, autocomplete: "one-time-code" },
],
submit: { label: "Sign in", tool: "sign_in_submit" },
cancel: { label: "Not now" },
};
},
},
{
name: "sign_in_submit",
description: "Complete the sign-in the card collected.",
inputSchema: { type: "object", properties: {} },
annotations: { readOnlyHint: false },
async execute({ code }) {
const response = await fetch("/api/auth/otp/verify", {
method: "POST",
credentials: "same-origin",
headers: { "content-type": "application/json", "x-csrf-token": window.CSRF_TOKEN },
body: JSON.stringify({ contact: pendingContact, code }),
});
// `signedIn: true` is the ONE thing the chat reads. On it, the widget
// re-asks your page for an identity token and the SAME conversation
// continues signed in — no reload, nothing retyped.
if (!response.ok) return { signedIn: false, error: "invalid_code" };
return { signedIn: true };
},
},
]);