Skip to content
AI Assistant

Guides

Let your users sign in from the chat

Give customers a sign-in card inside the conversation, on the accounts you already run: what they get, how to switch it on, and what stays with you.

On this page

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 when a question needs their account, the credentials go to your own site, and the conversation carries on signed in. No platform account is created, and nothing about their login reaches the assistant.

A question needs their account Your sign-in card opens in the thread Same conversation now signed in your site vouches with a short-lived signed proof

Yours end to end

  • Sign-in and sign-up are your product's. The card, login page and account creation are the ones you already run, and the form you return is drawn verbatim. AI Assistant never stores a password or creates a customer account; it only verifies the proof your site signs.

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 from Recognize signed-in customers, plus your customer login URL, which the hosted chat and step 4 use. Save, press Test identified launch, then publish.

The check is standards-only: an ES256 (or RS256) JWT verified against your JWKS for issuer, audience, subject, workspace claim, the launch nonce, 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: 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 });
});

2. Put a sign-in card in the chat

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 };
    },
  },
]);

sign_up and sign_out are optional companions on the same contract.

The fields are yours: email and password, an emailed code, or one 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 field vocabulary is in Forms and sign-in inside the chat.

A form may answer with another form, drawn next in the same card, so a multi-step login (email or phone, a code, a consent box) is still one sign_in tool. Keep step state in your own page; the form has no hidden field.

For a one-time code, the card is two steps: where to send it, then 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: [
        // `autocomplete: "username"` is not decoration. The chat reads it to
        // know this box asks for a CONTACT, and refuses a bare one-time code
        // typed here before your endpoint is ever called (#3518) — the
        // predictable mistake, since the next card asks for exactly that code.
        { 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 }) {
      // Belt and braces: the card refuses a bare code, and so does this. A
      // tool that answers "sent" for a value nothing can be sent to leaves the
      // visitor waiting for a message that will never arrive.
      if (/^\d{4,8}$/.test(String(contact ?? "").replace(/\s+/g, ""))) {
        return { signedIn: false, error: "not_a_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 type text, so a phone can offer the SMS it just received.

With no tool registered, nothing is invented: the assistant gives your real sign-in link or sends the visitor to your login URL at the top level (step 3).

3. Know what happens where

Where the chat runsWhat the Sign in control does
Embedded on your page, sign_in registeredThe card opens in the thread. Nothing leaves the page.
Embedded on your page, no sign_in toolYour page goes to your login URL with return_to and a one-time bmai_nonce, then hands the proof back to the widget.
Your hosted chat addressThe same redirect, with the proof in the URL fragment.
Your iOS or Android appThe app runs its own login over the native bridge.
No sign_in tool and no login URL publishedNothing is offered.

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

A customer already signed in to your product is signed in to the chat without a form. The widget opens your login URL once per tab in a hidden frame with bmai_prompt=none, which, like prompt=none, means answer only from an existing session. Your login redirects 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, sending a signed-out visitor straight back to return_to with #error=login_required (the OpenID Connect answer) — or with nothing;
  • the probe finishes within three seconds; it fails silently, so the visitor stays a guest with the Sign in control still there.

It only ever adds an identity and is not attempted inside a native app. Where third-party cookies are blocked, the handoff can run once as a page navigation, only for a browser that has signed in to your chat before: a first-time visitor is never sent to your login page, and error=login_required ends the attempt for good.

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 delegated tools also need Allow delegated customer account access on the provider. Set the levels in Connect your MCP server as assistant tools. Each site at demo.busymate.ai has a demo customer to sign in as.

Verify

  1. Signed out, ask something that needs an account: the card appears in the thread.
  2. Sign in from the card: the same conversation continues and your earlier question is answered.
  3. Open the tool call's details: the password shows as •••••.
  4. Sign in to your product in another tab, then open the hosted chat: it is signed in with no form.

Next

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. 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.