> ## Documentation Index
> Fetch the complete documentation index at: https://docs.bubblav.com/llms.txt
> Use this file to discover all available pages before exploring further.

# In-App Chatbot Integration

> Embed the BubblaV chat widget inside your own app and run MCP tools against your MCP server as your logged-in user.

Embed the BubblaV chat widget inside your own application — a SaaS dashboard, customer portal, or internal tool — and let its AI agent execute MCP tools against **your** MCP server **as the currently logged-in user**.

The division of responsibility is deliberate:

* **BubblaV authenticates.** Your backend mints a short-lived signed token per logged-in user. BubblaV verifies it, binds the identity to the conversation server-side, and attaches a freshly-signed per-call token to every outbound MCP tool call.
* **Your server authorizes.** BubblaV never decides what a user may do. Your MCP server receives the verified identity with every tool call and scopes every effect to that user's ID.

<Note>
  This guide is for **your own MCP server** — i.e. an MCP server you operate and connect to BubblaV so the chatbot can call your tools. To instead consume BubblaV's own data and tools from an MCP client, see [MCP Server](/developer-guide/mcp-server).
</Note>

***

## Use case: a public bot and an internal bot

A common deployment runs **two separate chatbots** from one BubblaV account, each with a different audience:

* **Public website chatbot** — mounted on your marketing site to serve visitors and customers. It answers from your public content, works for anonymous visitors (BubblaV issues a server-side visitor session automatically), and typically needs no tools at all.
* **Internal chatbot** — mounted inside your own app, behind your existing login, to serve employees. It authenticates with [end-user identity](#mint-the-end-user-token), so every conversation is bound to the signed-in employee and answers are personalized to whoever is asking — "my open orders", "my assigned tickets", "my team's data" — instead of generic FAQ answers.

Configure them as **two separate websites** in your dashboard: each website carries its own knowledge base, widget configuration, and bot instructions, so internal knowledge never leaks into the public bot. One widget configuration per page applies — mounting the other bot's website in the same document is rejected with `widget_conflict`; switch between them with a full page navigation (see [Embed the widget](#embed-the-widget)).

The internal bot does more than answer. Give it tools — an identity-enabled [MCP server](#harden-your-mcp-server) (this guide) or an endpoint connected through [Custom Tools](/user-guide/integrations/custom-tools) — and it can **act on behalf of the logged-in employee**: read the records that employee is allowed to see, and create or change data only after the employee approves it in an [approval card](#approval-cards). Your server stays in control: it receives BubblaV's freshly-signed per-call token on every tool call, verifies it, re-resolves the caller's current permissions from `sub` on each call, and scopes every effect to that user — so when an employee loses access in your system, their tool access ends on the next call, not when a token expires.

For example, an order-automation product can ship exactly this pair: a marketing bot on the public site answering pricing and feature questions for anyone, plus an in-dashboard assistant that lists the signed-in operator's own mailboxes and pending orders, previews a change, and then approves an order or invites a teammate after an in-chat confirmation — with every effect scoped to that operator's workspace and an audit trail on writes.

***

## How it works

1. A user signs in to **your** app as usual. Your backend mints a short-lived **end-user identity token** (an HS256 JWT) for that user.
2. The token is handed to the BubblaV widget (script attribute or SDK call).
3. The widget presents the token on protected requests. BubblaV verifies it and authorizes the website-scoped conversation before accessing or persisting its data. Protection covers chat, conversation history/list, detail and mark-read, realtime subscriptions, and direct tool execution.
4. When the agent calls a tool on your MCP server, BubblaV mints a **fresh per-call token** for that invocation and sends it along with pinned identity headers.
5. Your MCP server verifies the per-call token and executes the tool **scoped to that user** — reading and writing only that user's data.

Anonymous visitors can still chat using a server-issued visitor session, bootstrapped through `POST /api/public/widget/session` and carried in `X-BubblaV-Visitor-Token`. A locally stored visitor ID is not proof of ownership. Anonymous conversations stay anonymous: signing in requires a new verified conversation, not promotion of the old one. Verified conversations remain scoped to the same website and subject; dashboard principals have their own server-authenticated scope. Your MCP server decides what, if anything, anonymous callers may do.

<Note>
  This guide describes the implemented repository contract, not a verified deployment. The conversation-authorization migration has been authored but not applied here; a maintainer must review/apply it and verify the deployed web app and widget together before relying on these semantics in production.
</Note>

***

## Prerequisites

Before you start, you need:

* A BubblaV account with the chat widget installed. Outbound MCP tool calls count against your plan's [MCP call allowance](/developer-guide/mcp-server#rate-limits).
* An MCP server reachable over **HTTPS** (the chatbot calls it over the public internet).
* In your BubblaV dashboard:
  1. On your MCP server's configuration, enable **End-user identity**.
  2. In your website settings, generate the **website signing secret**. It is shown **once** — copy it immediately and store it in your backend's secret store.
  3. Configure identity-enabled MCP with **zero static credentials**: no owner API key, static bearer, or custom credential headers. Enabled mode discards static credentials entirely; only BubblaV's per-call identity credentials are forwarded. Identity-disabled integrations may still use static credentials.

<Warning>
  The signing secret is a shared secret between your backend and BubblaV. Store it server-side only — never ship it to the browser, a mobile bundle, or a public repository. If it leaks, rotate it (see [Secret rotation](#secret-rotation)).
</Warning>

***

## Mint the end-user token

For each logged-in user, your backend mints an HS256 JWT signed with your website signing secret. For JavaScript/TypeScript, use the shared npm SDK rather than maintaining token signing and browser lifecycle code yourself:

```bash theme={null}
npm install @bubblav/ai-chatbot-sdk
# Add your framework wrapper, for example:
npm install @bubblav/ai-chatbot-react
```

These examples describe the SDK source contract, not a claim of a published release or a verified deployment.

| Claim   | Required    | Value                                                                                                                                 |
| ------- | ----------- | ------------------------------------------------------------------------------------------------------------------------------------- |
| `iss`   | Yes         | `bubblav-end-user` (exact)                                                                                                            |
| `aud`   | Yes         | Your BubblaV website ID as an exact string; mint a single audience, not a list                                                        |
| `sub`   | Yes         | A nonempty, stable, opaque user ID (max 128 chars). Prefer a database ID over an email — it must never change for the same user       |
| `exp`   | Yes         | Expiration time (epoch seconds). A 5–15 minute lifetime is recommended (10 minutes in these examples), not an enforced lifetime range |
| `iat`   | Yes         | Issued-at time (epoch seconds)                                                                                                        |
| `nbf`   | Recommended | Not-before time (epoch seconds)                                                                                                       |
| `jti`   | Recommended | A unique token ID                                                                                                                     |
| `email` | No          | Display snapshot only (max 256 chars). Never used for authorization                                                                   |
| `name`  | No          | Display snapshot only (max 256 chars). Never used for authorization                                                                   |

Verification uses a 30-second clock tolerance, so small clock skew between your servers and BubblaV is fine.

Use the **UTF-8 bytes of the displayed hex string** as the HS256 secret; do not hex-decode it. The verifier requires `exp`, `iat`, and `sub`. Its JWT library also accepts an audience array containing the website ID; integrations should mint the exact single-string audience shown here, and the MCP verifier examples below enforce that shape.

<Tabs>
  <Tab title="Node.js">
    ```javascript theme={null}
    import { createEndUserToken } from '@bubblav/ai-chatbot-sdk/server';

    // Server-only: the SDK does not read environment variables automatically.
    const identityOptions = {
      websiteId: process.env.BUBBLAV_WEBSITE_ID ?? '',
      signingSecret: process.env.BUBBLAV_END_USER_SIGNING_SECRET ?? '',
      ttlSeconds: 600, // Optional; defaults to 600 seconds.
    };

    export async function mintEndUserToken(user) {
      // user comes from your authenticated backend session, never request input.
      return createEndUserToken(
        { userId: user.id, email: user.email, name: user.name },
        identityOptions,
      );
    }
    ```
  </Tab>

  <Tab title="Python">
    ```python theme={null}
    import os
    import time, uuid
    import jwt  # PyJWT

    def mint_end_user_token(user):
        now = int(time.time())
        payload = {
            "iss": "bubblav-end-user",
            "aud": os.environ["BUBBLAV_WEBSITE_ID"],
            "sub": user["id"],              # opaque, stable, ≤128 chars
            "iat": now,
            "nbf": now,
            "exp": now + 10 * 60,           # 10 minutes
            "jti": str(uuid.uuid4()),
            "email": user.get("email"),     # optional snapshot
            "name": user.get("name"),       # optional snapshot
        }
        return jwt.encode(payload, os.environ["BUBBLAV_END_USER_SIGNING_SECRET"], algorithm="HS256")
    ```
  </Tab>
</Tabs>

<Warning>
  Mint tokens on your backend only. Your signing secret in the browser would let any visitor mint tokens as any user.
</Warning>

***

## Embed the widget

Pass the token when the widget loads, or supply it (and refresh it) through the SDK.

### Option 1: Token at load time

```html theme={null}
<script
  src="https://www.bubblav.com/widget.js"
  data-site-id="YOUR_WEBSITE_ID"
  data-bubblav-user-token="END_USER_TOKEN"
  async
></script>
```

### Option 2: Identify after load

```javascript theme={null}
BubblaV.identify(currentEndUserToken);
```

### Managed on-demand refresh (recommended)

```ts theme={null}
import { mountBubblaVWidget } from '@bubblav/ai-chatbot-sdk';

export function mountChat(websiteId: string, initialToken: string) {
  const options = {
    websiteId,
    identity: { token: initialToken, tokenEndpoint: '/api/bubblav/identity-token' },
    onError: (error: { code: string }) => console.error('Widget error', error.code),
  };
  const connection = mountBubblaVWidget(options);
  void connection.ready.catch(error => console.error('Widget readiness failed', error.code));
  return {
    logout() { connection.update({ ...options, identity: { token: null } }); },
    dispose() { connection.dispose(); },
  };
}
```

Mount only in a browser lifecycle and call `dispose()` on unmount. A same-configuration remount reuses the script; it does not create a second widget. A concurrent owner or different website/source in the same document reports `widget_conflict`. Use full document navigation when changing website configuration; removing the script cannot reset the loader.

Omitting `identity` means unmanaged identity. Explicit `{ token: null }` means logout: abort pending refresh, clear the token, and stop refreshing until a non-null token is supplied. Empty tokens are errors. The SDK deduplicates simultaneous refresh requests and discards late responses after logout, token/refresh-source change, timeout, or disposal. An unchanged initial token prop does not overwrite a token already refreshed by the SDK.

`tokenEndpoint` performs a same-origin, no-store GET with a lifecycle `AbortSignal` and requires an OK JSON response with a nonempty string `token`. For POST, custom headers, or another credential policy, replace `tokenEndpoint` with `getToken: async ({ signal }) => token`; never supply both. The SDK awaits the provider and calls `identify` itself. Keep the provider stable unless its behavior changes. Unlike this provider, global `onIdentityNeeded` and `on('identityNeeded', callback)` listeners **ignore return values**: a low-level listener must deliver the token via `BubblaV.identify(token)`. Do not register a second low-level refresh listener alongside SDK-managed identity.

A minimal authenticated host endpoint (adapt the app-specific authentication helper):

```ts theme={null}
// app/api/bubblav/identity-token/route.ts
import { createEndUserToken } from '@bubblav/ai-chatbot-sdk/server';
import { getAuthenticatedUser } from '@/lib/auth';

const identityOptions = {
  websiteId: process.env.BUBBLAV_WEBSITE_ID ?? '',
  signingSecret: process.env.BUBBLAV_END_USER_SIGNING_SECRET ?? '',
};

export async function GET() {
  const headers = { 'Cache-Control': 'no-store' };
  const user = await getAuthenticatedUser();
  if (!user) return Response.json({ error: 'Unauthorized' }, { status: 401, headers });
  const token = await createEndUserToken(
    { userId: user.id, email: user.email },
    identityOptions,
  );
  return Response.json({ token }, { headers });
}
```

### Custom loader URL

For a local or self-hosted loader, supply `widgetSrc`. The default is `https://www.bubblav.com/widget.js`. Only nonempty relative or absolute HTTP(S) URLs are accepted; relative URLs resolve against the document base URI, and path/query are retained. If the URL includes an `id` query parameter, it must match `websiteId`. Never put an identity token in the URL.

```tsx theme={null}
<BubblaVWidget websiteId="your-website-id" widgetSrc="http://localhost:3001/widget.js" />
```

Import `BubblaVWidget` from the React package in that example. The equivalent Vue prop is `widget-src`, Angular input is `[widgetSrc]`, and vanilla option is `mountBubblaVWidget({ websiteId, widgetSrc })`. Select the source at mount time. Changing it terminates the connection with `widget_conflict`; the SDK does not switch loaders, retry automatically, or silently fall back to production.

<Note>
  For full SDK details (all methods and events), see [SDK Reference](/developer-guide/sdk-reference).
</Note>

### Token expiry behavior

The widget handles short token lifetimes for you:

* **Proactive refresh:** before a send, if the token expires within 60 seconds, the widget requests a fresh token from the host or configured refresh endpoint. An approved tool continuation also refreshes before continuing the same approval decision.
* **Chat-only expired-send recovery:** `/api/chat` short-circuits an expired token before quota, message logging, or tool dispatch and sends `X-BubblaV-Identity-State: expired`. After successful refresh, the widget resends the message once. This is not a blanket retry rule for history, realtime, or direct tool execution, and does not authorize retrying a dispatched write.
* **Refresh failure:** an unsuccessful response, a successful response missing a nonempty `token`, or no host delivery via `identify(token)` within 5 seconds shows a dismissible "session expired" notice and does **not** resend. Transient failures are not logout and must not silently downgrade an identity-bound conversation to anonymous.
* **Logout:** with the npm SDK, update to `identity: { token: null }` or dispose the managed connection. Low-level integrations must unregister refresh, abort pending work, and call `BubblaV.identify(null)` explicitly. This clears the token and prepared Authorization header. Do not carry the previous user's conversation or pending approval into a new login.

<Warning>
  A conversation's identity state is immutable. Verified arrival on an anonymous conversation returns `409 anonymous_locked`; a different principal on a bound conversation returns `403 identity_mismatch`. Unknown or wrong-website conversations return `404`, missing/invalid proof returns `401`, and authorization storage failures return `503`. These checks fail closed, not anonymous-success. Start a new conversation when signing in from anonymous mode or switching users.
</Warning>

***

## Harden your MCP server

BubblaV sends a **freshly-signed per-call JWT** with every MCP tool call. Verify it on every request and scope every effect to its `sub`.

### Per-call token claims

| Claim                 | Value                                                                         |
| --------------------- | ----------------------------------------------------------------------------- |
| `iss`                 | `bubblav` (exact)                                                             |
| `aud`                 | Your BubblaV website ID — exact string                                        |
| `sub`                 | The chatting user's nonempty ID (max 128 chars; same value your tokens carry) |
| `website_id`          | Your BubblaV website ID (custom claim)                                        |
| `exp`                 | 5 minutes after signing; reusable within this validity window                 |
| `iat` / `nbf` / `jti` | Issued-at, not-before, unique token ID                                        |

The token is minted fresh for every invocation with the **current** signing secret. Requiring `iss: bubblav` separates per-call tokens from host identity tokens (`iss: bubblav-end-user`), but issuer separation is **not replay prevention**. A per-call token is reusable within its 5-minute validity; `jti` alone is not a consumed-once receipt. Enforce your own authorization and any write idempotency/replay controls on your server.

### Pinned headers

Alongside the JWT, BubblaV sends these headers. They are pinned by BubblaV's server and cannot be overridden by tool arguments or the model:

| Header                 | Value                                           |
| ---------------------- | ----------------------------------------------- |
| `X-BubblaV-User-Id`    | The verified `sub` (absent for anonymous calls) |
| `X-BubblaV-Website-Id` | Your BubblaV website ID                         |
| `X-BubblaV-Auth-State` | `verified` or `anonymous`                       |

### Verification rules

1. **Verify the per-call JWT on every request**: HS256 signature, `iss` = `bubblav`, exact-string `aud` = your website ID, required unexpired `exp`, and required nonempty string `sub` of at most 128 characters. Honor `nbf` when present.
2. **Trust `X-BubblaV-*` headers only alongside a valid JWT.** On their own they are unauthenticated data and must be ignored.
3. **Treat anonymous as unauthenticated.** Only a genuinely absent Authorization header may enter your anonymous-discovery policy. A present empty, malformed, or invalid credential must fail rather than become anonymous. `X-BubblaV-Auth-State` alone never proves identity. Explicitly reject missing identity for protected tools, including `tools/call` inside batches.
4. **Scope every effect to `sub`.** Every read and write must be filtered by the verified user ID — database queries, file access, downstream API calls, everything.
5. **Never trust tool arguments for identity.** A user ID arriving in a tool argument is attacker-controlled input. Only the JWT `sub` (and, once verified, the matching `X-BubblaV-User-Id`) identifies the caller.

<Tabs>
  <Tab title="Node.js">
    ```javascript theme={null}
    import { BubblaVTokenError, verifyMcpRequest, verifyMcpToken } from '@bubblav/ai-chatbot-sdk/server';

    const verificationOptions = {
      websiteId: process.env.BUBBLAV_WEBSITE_ID ?? '',
      signingSecret: process.env.BUBBLAV_END_USER_SIGNING_SECRET ?? '',
      previousSigningSecret: process.env.BUBBLAV_PREVIOUS_SIGNING_SECRET || undefined,
    };

    export async function requireBubblaVIdentity(req) {
      // null only for absent Authorization. Malformed or invalid credentials throw.
      const identity = await verifyMcpRequest(req.headers, verificationOptions);
      if (!identity) throw new BubblaVTokenError('Authentication required');
      return identity; // { sub, websiteId, email? }; resolve current permissions from sub.
    }

    // A transport that already supplies a raw JWT can verify without header checks:
    export async function verifyRawPerCallToken(token) {
      return verifyMcpToken(token, verificationOptions);
    }
    ```
  </Tab>

  <Tab title="Python">
    ```python theme={null}
    import os
    import jwt  # PyJWT

    SECRETS = [value for value in (
        os.environ.get("BUBBLAV_END_USER_SIGNING_SECRET"),
        os.environ.get("BUBBLAV_PREVIOUS_SIGNING_SECRET"),
    ) if value]

    def require_bubblav_identity(authorization_header):
        if authorization_header is None:
            return None  # Only a genuinely absent header is anonymous.
        parts = authorization_header.split()
        if len(parts) != 2 or parts[0].lower() != "bearer":
            raise ValueError("Invalid Authorization credential")
        for secret in SECRETS:
            try:
                payload = jwt.decode(
                    parts[1], secret, algorithms=["HS256"],
                    issuer="bubblav",
                    audience=os.environ["BUBBLAV_WEBSITE_ID"],
                    options={"require": ["exp", "sub"], "strict_aud": True},
                )
                sub = payload.get("sub")
                if not isinstance(sub, str) or not sub.strip() or len(sub) > 128:
                    raise ValueError("Invalid subject")
                return payload  # scope every effect to sub
            except jwt.PyJWTError:
                continue
        raise ValueError("Invalid per-call token")
    ```
  </Tab>
</Tabs>

The JS helpers require nonempty website/secret configuration and accept only HS256, `iss: bubblav`, an exact-string audience, required unexpired `exp`, and a nonempty `sub` of at most 128 characters. They validate finite time claims and matching `website_id` when present. `nbf` has 30 seconds of tolerance, but expiry is strict: `now >= exp` fails. Only the explicit current and optional previous secret are tried, using UTF-8 bytes. `verifyMcpRequest` additionally checks nonempty user/website identity headers against the verified claims. Configuration and credential failures throw `BubblaVTokenError`; missing identity never overrides an invalid configuration.

For discovery, call `verifyMcpRequest` and explicitly allow `null` only on the methods you choose to expose anonymously. For every protected tool handler, use a required-identity check like the Node example and scope all queries/writes to the returned `sub` after resolving current permissions. Map credential failures to your transport's authentication response. These helpers do not implement discovery policy, authorization, MCP transport, audit storage, replay prevention, or approval. A valid credential does not prove a human approved a write.

### Write-preview pattern for destructive tools

For tools that create or change data, add a top-level `confirmed` boolean to the tool's argument schema. BubblaV's executor strips caller-supplied values and injects `confirmed: true` for the approved branch of its approval flow (see [Approval cards](#approval-cards)). Until then, your tool can return a preview that changes nothing. This is an executor-managed **consent UX convention**, not cryptographic proof that a human approved the exact arguments; your server must still authorize the write.

***

## Approval cards

How BubblaV asks the chatting user before your tool runs is driven by the annotations your tool declares in its MCP schema:

* **`readOnlyHint: true`** — the tool only reads data. It runs without an approval card.
* **`destructiveHint: true`** (or the tool otherwise writes data) — the user sees an approval card showing the tool name and arguments. The tool runs only after the user approves; denying it never reaches your server.

The top-level `confirmed` argument distinguishes the executor's approved path from a preview. Neither that boolean nor the per-call identity JWT is an independently verifiable human-consent receipt. For stronger guarantees, implement a server-issued, single-use preview/confirmation token bound to the exact intended change; that is additional hardening, not part of the current contract. After identity refresh, an approval continuation resumes the same decision rather than creating a second write.

<Note>
  Annotate your tools accurately. Marking a write tool `readOnlyHint: true` makes it run without user approval — BubblaV trusts these hints for the approval flow.
</Note>

### Timeout and cancellation

MCP execution has a 30-second timeout and receives the incoming request's cancellation signal on every execution path. Timeout or cancellation does **not** prove rollback: the operation may already have completed. Check its status before retrying and **never automatically repeat a dispatched write**. Chat's pre-dispatch expired-token recovery above is a separate, bounded flow.

Identity-enabled MCP clients are isolated by `server.id::url::websiteId::sha256(sub).slice(0,16)`, including website separation for the same subject. Cache eviction is FIFO with a 5-minute creation TTL, not LRU or a token replay ledger.

***

## Secret rotation

Coordinate **both directions**: your backend signs identity tokens that BubblaV verifies, while BubblaV signs per-call tokens that your MCP server verifies. The verifier examples above try the current key first and the optional previous key second.

1. Rotate in the BubblaV dashboard. Copy the new secret shown once; BubblaV retains the previous verification secret for **24 hours** and signs outbound per-call tokens with the current (new) secret.
2. Update `BUBBLAV_END_USER_SIGNING_SECRET` to the new value and move the old value to `BUBBLAV_PREVIOUS_SIGNING_SECRET`. Redeploy your mint endpoint and MCP verifier together. During overlap, BubblaV accepts inbound identity tokens signed with either key; your server accepts new-key per-call tokens first and previous-key tokens as a fallback.
3. After the 24-hour grace window, remove `BUBBLAV_PREVIOUS_SIGNING_SECRET` and redeploy. Verify that only current-key tokens work in both directions.

There is no unconditional zero-downtime guarantee: the period between dashboard rotation and your redeployment can reject new-key outbound tokens. BubblaV invalidates only the rotating process's secret cache; other processes refresh their 60-second cache independently. Coordinate rollout and verify both directions rather than assuming an instantaneous global switch.

<Warning>
  After grace, a previous-key token is no longer accepted under that key. An otherwise unexpired token normally fails signature verification; it is not automatically classified as expired and does not downgrade a protected conversation to anonymous. Environment-based previous-key fallback has no automatic grace timer: removing the previous environment value is your responsibility.
</Warning>

***

## Test checklist

* [ ] A logged-in user chats, and tool calls arrive at your MCP server with a valid per-call JWT (`iss: bubblav`, correct `aud`, unexpired) and `X-BubblaV-Auth-State: verified`.
* [ ] Effects are scoped to `sub`: user A can never read or change user B's data, even by asking the bot directly.
* [ ] An anonymous visitor can chat; calls arrive with `X-BubblaV-Auth-State: anonymous`, no `X-BubblaV-User-Id`, and your server rejects or limits them as intended.
* [ ] A request with tampered `X-BubblaV-*` headers but no valid JWT is treated as unauthenticated.
* [ ] A user ID inside a tool argument is ignored — identity comes only from the verified token.
* [ ] A destructive tool triggers an approval card in the widget; denying it results in no call to your server; approving it arrives with `confirmed: true`.
* [ ] Let a token expire mid-session: the widget refreshes it proactively (or retries once reactively) and the user keeps their identity.
* [ ] Rotate and verify both directions during overlap: old/new inbound identity tokens and new/previous outbound per-call tokens. After grace and removal/redeployment, old-key tokens fail.
* [ ] Verify transport isolation across chat, history/list, detail, mark-read, realtime, and direct execute. Missing proof is denied; caller-chosen IDs do not grant access; storage failures are `503` rather than empty-success history.
* [ ] Anonymous-to-login requires a new conversation (`409 anonymous_locked` on the old one); account switching cannot access the previous user's conversation. Logout clears identity, unregisters refresh, and detaches old subscriptions; remount registers once and ignores late old-user responses.
* [ ] No callback, transient endpoint failure, and an OK response missing `token` all show the session-expired notice without resend after the 5-second host wait.
* [ ] Let an approval card outlive the identity token: refresh succeeds and resumes the same decision, without duplicate writes.
* [ ] Configure no static credentials for identity-enabled MCP; anonymous discovery sends no owner bearer. The same `sub` on two websites cannot share an identity-client cache entry.
* [ ] A timed-out or cancelled write is treated as outcome-unknown; inspect status and do not automatically retry it.

## Troubleshooting

| Symptom                                                      | Likely cause                                                                               | Fix                                                                                                                                                                      |
| ------------------------------------------------------------ | ------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| Chat works but tools run as anonymous                        | The request has no verified identity, or the MCP integration is not identity-enabled       | Supply an initial token and register refresh; check website/secret configuration rather than assuming expired credentials downgrade to anonymous                         |
| Your server rejects per-call tokens with a signature error   | Wrong current key, missing overlap key, or old key retained past the planned grace period  | Deploy coordinated current/previous keys in both directions and remove the previous key after grace                                                                      |
| Every tool call acts as the same user                        | Your server trusts `X-BubblaV-User-Id` without verifying the JWT, or ignores `sub`         | Verify the JWT first, then scope all effects to `sub`                                                                                                                    |
| `403 identity_mismatch` on chat                              | The conversation is bound to a different user ID                                           | Start a new conversation; never reuse conversations across users                                                                                                         |
| Widget shows "session expired" notice                        | Refresh failed or did not provide a nonempty token within 5 seconds                        | Check endpoint auth and response shape. The SDK awaits `getToken`; global event callback return values are ignored. Retry only the unsent message after refresh succeeds |
| Approval card never appears for a write tool                 | Tool annotated `readOnlyHint: true`                                                        | Correct the tool's annotations in your MCP server                                                                                                                        |
| Identity never verifies at all                               | End-user identity not enabled on the MCP server, or no signing secret generated            | Enable it in the dashboard and generate the website signing secret                                                                                                       |
| `409 anonymous_locked` after login                           | The conversation was created anonymously                                                   | Start a new verified conversation; anonymous history is not promoted                                                                                                     |
| `401` on history/realtime or `503 authorization_unavailable` | Missing/invalid proof, expired identity, or unavailable authorization storage              | Restore valid session/identity proof or service availability; do not fall back to caller-selected visitor IDs                                                            |
| Old-user history or duplicate refresh requests after remount | Host lifecycle was not torn down or old requests delivered late                            | Unsubscribe, abort pending refresh, call `identify(null)`, and start the next login in its own conversation                                                              |
| Schema discovery fails with identity enabled                 | A deployment still uses owner/static credentials or the server rejects anonymous discovery | Remove static credentials and verify the enabled adapter is deployed; allow unauthenticated discovery only if appropriate, never protected tool calls                    |
| Write reports timeout/cancellation                           | Dispatch may have completed remotely                                                       | Check current state before any user-directed retry; never automatically repeat the write                                                                                 |

***

## Next Steps

<CardGroup cols={2}>
  <Card title="MCP Server" icon="plug" href="/developer-guide/mcp-server">
    Connect MCP clients to BubblaV's own MCP server
  </Card>

  <Card title="SDK Reference" icon="code" href="/developer-guide/sdk-reference">
    Full widget SDK methods and events
  </Card>
</CardGroup>
