> For the complete documentation index, see [llms.txt](https://docs.flowcp.ai/llms.txt). Markdown versions of documentation pages are available by appending `.md` to page URLs; this page is available as [Markdown](https://docs.flowcp.ai/guides/embed-ai-chat-widget.md).

# Embed an AI chat widget

The **chat widget** lets your own end-users talk to your published MCP server from inside your app — no AI client required. You add one line of HTML and get either a floating chat launcher or a chat mounted **inline** in your own page. The assistant can call your server's enabled tools, running each request in your app's authorization context.

Under the hood it's the same agentic loop as the [Playground](/guides/chat-test-your-mcp-server.md), exposed on a public, per-widget surface and isolated inside a cross-origin iframe so it can never read your page's DOM or cookies. The assistant has access to the server's enabled **tools, skills, prompts, widgets, and resources**. Your app can [tell it what the user is looking at](#ground-the-assistant-in-the-current-page), [make it speak first](#start-the-conversation-from-your-app), and optionally [retain conversations](#conversation-history) for resumable threads and follow-up.

## What you need

1. A **published** server (the widget connects to its enabled tools).
2. An **OpenRouter API key** for the widget. Each widget has its **own** key (it is not shared with the Playground or other widgets) — the widget can't chat until you set one. Create a key at [openrouter.ai/keys](https://openrouter.ai/keys).

## Create a widget

1. Open your app and go to **Distribute → Chat widget**. The page targets the branch selected in the header — switch branches there to expose a different one.
2. Click **New chat widget**. A widget is created with a unique **public key**.
3. **Set its OpenRouter API key** (required). It's stored encrypted and never shown again — only the last four characters are displayed. Use **Replace** to rotate it.
4. Add every site that will embed the widget under **Allowed origins** (one origin per line, e.g. `https://app.example.com`). Only these sites may frame the widget.
5. Click **Save changes**.

## Choose the model(s)

Add one or more OpenRouter model ids under **Models** (one per line), then pick the **Active model** the widget chats with. You manage the list; end-users don't choose.

## Connect multiple servers (connectors)

A widget can combine the tools of **several MCP servers** in one chat. Each attached server is a **connector**. The widget you created starts with one **primary** connector — this app's server — and you can add more from your workspace.

In the **Connectors** section of the widget:

* Click **Add connector** and pick any server in your workspace. Its enabled tools, prompts, skills, and resources are added to the same chat.
* **Reorder** connectors with the up/down arrows, **toggle** one off without removing it, or **remove** a non-primary connector. The **primary** connector can't be removed or disabled.

To keep tool names unique across servers, each connector's tools are **namespaced** with a short prefix shown in the list (for example `primary__` or `crm__`). The assistant is told about the prefixes and routes each call to the right server. Only **published** servers respond at chat time; an unpublished or unreachable connector is skipped for that turn rather than failing the whole chat.

You can also add **remote connectors** from the [Connector Library](/guides/mcp-connector-library.md) — external MCP servers like GitHub, Notion, or Linear that a workspace owner has enabled. They appear in **Add connector** tagged **Remote**; OAuth ones show a per-connector **Connect** button so each end-user signs in to that provider themselves.

## Add the snippet to your app

Copy the snippet shown on the widget and paste it just before `</body>` in your app:

```html
<script async src="https://api.flowcp.ai/embed.js" data-embed-key="emb_xxx"></script>
```

That's it — a floating **Chat** launcher appears in the corner. Clicking it opens the chat panel.

## Popup or inline

**Display mode** decides how the chat appears in your page:

* **Popup (floating launcher)** — the default. A launcher pill sits in a corner and opens the chat as an overlay above your page.
* **Inline (in your page)** — the chat is mounted **inside your own layout**, with no launcher and no overlay: use it for a dedicated "Assistant" page, a dashboard panel, or a sidebar. It's always open, so the chat header has no close button (**⤢** fullscreen still works).

For inline mode, add a **container** element where you want the chat to appear. The snippet on the widget card includes it once inline mode is saved:

```html
<div id="flowcp-chat"></div>
<script async src="https://api.flowcp.ai/embed.js" data-embed-key="emb_xxx"></script>
```

Two inline settings replace **Launcher text** and **Position**:

* **Container selector** — the CSS selector the widget mounts into (default `#flowcp-chat`). Any selector works, e.g. `.chat-panel` or `#sidebar .assistant`.
* **Height (px)** — how tall the chat renders (240–2000, default 600). If your container has a height of its own (say `height: 100%` inside a flex layout), the chat **fills the container** and this value is ignored.

You can also point a single widget at a different element on a specific page by adding `data-target` to the script tag — it overrides the configured selector:

```html
<div id="support-chat"></div>
<script
  async
  src="https://api.flowcp.ai/embed.js"
  data-embed-key="emb_xxx"
  data-target="#support-chat"
></script>
```

If no element matches, the widget logs an error in the browser console and falls back to the floating launcher so the chat is still reachable.

A page can also **override the display mode** itself with `data-display="inline"` or `data-display="popup"` on the script tag. This wins over the dashboard setting for that page only — handy when one widget is a floating launcher on most pages but lives inline on a dedicated "Assistant" page (or inside a component such as the [Bubble plugin element](#use-the-bubble-plugin)). Any other value leaves the configured mode in charge.

## Resize & fullscreen

End-users can size the chat to suit their screen — there's nothing to configure:

* **Resize** — drag the panel's inner top corner to change its **width and height**. The chosen size is remembered in that browser (per widget) for next time. (Popup mode only — an inline chat is sized by your page.)
* **Fullscreen** — click the **⤢** button in the chat header to expand the chat to the whole window, with the messages and composer centered in a readable column. Click **⤡** (or close) to return to the corner panel at its previous size.

## Customize the look

Each widget has:

* **Name** and **header title** — shown at the top of the chat panel.
* **Primary color** — the accent used for the launcher and the user's messages.
* **Display mode** — popup (floating launcher) or inline in your page; see [Popup or inline](#popup-or-inline).
* **Launcher text** and **Position** (popup mode) — the floating button's label and which corner it sits in.
* **Container selector** and **Height** (inline mode) — where the chat mounts and how tall it is.
* **Greeting** — an optional first message shown before the user types.
* **System prompt** — optional instructions that shape the assistant's persona.

## Preview the widget

Click **Preview** (top of the widget card) to see exactly how the chat will look in your app **before** you paste the snippet anywhere. The preview reflects your **current settings** — header title, launcher text, primary color, position, display mode, and greeting — including edits you haven't saved yet, so you can tweak the look and watch it update. In popup mode, click the launcher in the preview to open or close the panel; in inline mode the chat is shown mounted in a mock page.

It's a **visual** preview with sample messages — it doesn't send a real chat, so no OpenRouter key or live site is needed.

## Tools, prompts, skills, widgets & resources

The widget mirrors what your server exposes to any MCP client. Assistant replies are rendered as **markdown** (lists, code, links, emphasis) and **stream in live** as the model writes them. The model's **reasoning ("thinking")** is hidden by default behind a small **"Thinking…"** toggle above the answer — click it to expand or collapse the details. Once the turn finishes the toggle reads **"Thoughts"** and stays collapsed.

* **Tools & skills** — the assistant calls your enabled tools (and skills) directly to answer questions and take actions.
* **Slash commands** — type **`/`** in the message box (or open the **`+`** menu and pick **Skills**, **Prompts**, or **Resources**) to browse the server's enabled commands, filtered as you type. Picking a **prompt** fills any arguments. Picking a **skill or resource** drops a compact **`@name` mention chip** into the message box — it shows just the name, and the underlying reference is sent to the assistant when you send.
* **Writing messages** — the message box grows to fit multiple lines as you type. Press **Enter** to send and **Shift+Enter** to add a new line.
* **Widgets** — when a tool that has a linked widget runs, the widget renders **inline** in the chat (inside a sandboxed iframe), instead of a plain-text result.
* **Resources** — your server's enabled static resources are provided to the model as reference context automatically.

## Attach files

Let end-users share **images, audio, video, and documents** with the assistant. Attachments are turned **off by default** — enable them per widget so you stay in control of the extra model cost.

* **Turn it on** — in the widget config, switch on **Allow file attachments** and set **Max attachment size (MB)** (1–25 MB; default 10). The end-user composer's **`+`** menu then offers **Add files or photos**.
* **For end-users** — open the **`+`** menu and choose **Add files or photos** to pick files (up to **5 per message**). Each file shows a removable chip before sending; once sent, images and audio preview inline on the message and documents/video appear as a labelled file chip.
* **Supported types** — images (PNG, JPEG, GIF, WebP), audio (WAV, MP3), PDFs and common documents (DOC/DOCX, TXT, CSV, Markdown), and video (MP4, WebM, MOV).
* **Privacy** — files are read **in the browser** and sent **inside the chat request only**. FlowCP **never uploads or stores** them (the same posture as end-user tokens).
* **Model support** — the active model must be **multimodal** to understand an attachment. PDFs are **text-extracted automatically** for models without native file support. **Video is best-effort**: most models reject it, and the provider's message is shown when they do — switch to a video-capable model if you need it.

> **Cost note:** attachments are re-sent with each turn, so large files increase token usage on your OpenRouter key. Keep the size cap conservative and prefer small images.

## Run in each user's own context

By default the widget chats in the server's shared context. For **user-context (OAuth)** servers, turn on **Require end-user token** so each chat runs as the signed-in end-user. There are two ways the widget gets that token:

> **Multiple connectors:** each connector signs in **separately**. Connectors are connected from the composer's **`+`** menu → **Connectors** (a badge shows how many still need sign-in), and a one-line prompt above the composer offers **Connect** while any of them is signed out. Tokens are kept per connector and, if one expires mid-chat, only that connector is asked to reconnect.

### When does the user have to sign in?

That is the **Require sign-in before chatting** toggle, and it is separate from **Require end-user token**:

| Setting                                             | What the visitor sees                                                                                                                                                                                                   |
| --------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **Require sign-in before chatting — on**            | The composer is replaced by a **Connect** prompt. Nothing can be asked until the user signs in, and the whole conversation then runs in their own context.                                                              |
| **Require sign-in before chatting — off** (default) | Anyone can chat straight away. Connectors whose tools need the user's own authorization stay **closed** until that user signs in — the assistant answers what it can and asks them to connect when a request needs one. |

Either way a connector is **never** called with anyone else's credentials: a connector that needs a per-user token and doesn't have one is simply not opened for that turn.

> **Upgrading:** widgets that gated the chat before this setting existed (those with **Require end-user token** on, or a `user_oauth` primary server) keep doing so — they were switched to **Require sign-in before chatting = on**. Turn it off to let visitors chat before they sign in.

### Option A — the widget signs the user in (recommended)

With **Require end-user token** on, the widget offers a **Connect** button (in place of the composer when **Require sign-in before chatting** is also on, otherwise just above it). The user clicks it, a popup runs your server's OAuth (the same flow FlowCP uses everywhere), and the widget receives the user's token automatically — **nothing to wire up on your page.** This is the easiest path for no-code hosts like **Bubble**, where getting the user's token into the page is otherwise hard. The token lives only in the widget (per browser tab) and is **never stored** by FlowCP.

```html
<!-- That's the whole integration — the widget handles sign-in itself. -->
<script async src="https://api.flowcp.ai/embed.js" data-embed-key="emb_xxx"></script>
```

### Option B — pass the token yourself

If your app already has the signed-in user's access token, hand it to the widget and skip the Sign-in button:

```html
<script async src="https://api.flowcp.ai/embed.js" data-embed-key="emb_xxx"></script>
<script>
  // Call this once you have the signed-in user's access token.
  window.FlowCPChat.setToken('the-end-user-access-token');
</script>
```

The token is forwarded to your MCP server with each tool call and is **never stored** by FlowCP. You can also set it up-front with a `data-token` attribute on the script tag.

> **Bubble apps:** Bubble does not expose a signed-in user's OAuth access token to the page, so there is nothing to hand to `setToken()` unless your app obtains a token some other way (for example, your users sign in through Auth0, Clerk or another IdP behind your Bubble front end). For a plain Bubble-OAuth server, **Option A** is the supported path: one **Connect** click per browser tab, remembered across pages.

### Controlling the widget from JavaScript

The loader exposes a small API on `window.FlowCPChat`. In Bubble, call these from a **Run javascript** action (Toolbox plugin) in any workflow.

* `FlowCPChat.open()` / `FlowCPChat.close()` / `FlowCPChat.toggle()`
* `FlowCPChat.setToken(token)` — set the token for the **primary** connector.
* `FlowCPChat.setToken(connectorId, token)` — set the token for a **specific** connector (multi-connector widgets).
* `FlowCPChat.setTokens({ [connectorId]: token })` — set several connector tokens at once.
* `FlowCPChat.setContext({ … })` — tell the assistant what the user is looking at; see [Ground the assistant in the current page](#ground-the-assistant-in-the-current-page).
* `FlowCPChat.sendMessage(text)` — send a message **as the user** and open the panel.
* `FlowCPChat.prompt(instruction)` — have the **assistant speak first**, following an instruction the user never sees; see [Start the conversation from your app](#start-the-conversation-from-your-app).
* `FlowCPChat.reset()` — start a fresh conversation (same as the **New chat** button).
* `FlowCPChat.isOpen()` — whether the panel is currently shown (always `true` inline).

Calls made before the widget has finished loading are queued and run once it's ready, so it's safe to call them right after the snippet.

The loader also dispatches **events on `window`** so your page can react without polling: `flowcp:ready` (the chat accepts messages), `flowcp:open` and `flowcp:close` (the panel was shown or hidden, whether by your code, the launcher or the ×), and `flowcp:error` (the widget failed to load; `event.detail.message` says why).

```js
window.addEventListener('flowcp:ready', () => FlowCPChat.setContext({ page: 'home' }));
window.addEventListener('flowcp:close', () => console.log('chat closed'));
```

## Use the Bubble plugin

Bubble apps don't need the script tag, an HTML element or the Toolbox plugin: the **FlowCP - Embedded Chat** Bubble plugin ([source](https://github.com/mslma/Bubble-Plugin-FlowCP---Embedded-Chat)) wraps the widget in a single visual element.

1. Install the plugin in your Bubble app and paste the widget's public key into **Plugins → FlowCP - Embedded Chat → FlowCP embed key**. It is an app-level setting — one key for every page.
2. Drop a **FlowCP Chat** element on the page (one per page — the widget is a page-level singleton). Make sure the page's origin (`https://<app>.bubbleapps.io` and any custom domain) is under the widget's **Allowed origins**.
3. Pick a **Display mode**: **Inline (inside this element)** renders the chat in the element's box (size the element to size the chat), **Popup (floating launcher)** shows the corner launcher instead, **Widget setting** follows the dashboard.

> The plugin always loads the chat from `https://api.flowcp.ai`, and one Bubble app runs one widget. For a self-hosted FlowCP, or two widgets in the same app, use the script snippet above instead of the element.

The element exposes the host API as Bubble building blocks:

* **Fields** — **End-user token** (leave empty to let the widget's **Connect** button run your Bubble OAuth) and **Page context (JSON)**, both applied live when their value changes; **Open on page load** for popup mode. Whether users must sign in before they can chat is the widget's own **Require sign-in before chatting** setting in the FlowCP dashboard, not a plugin field.
* **Actions** — **Open / Close / Toggle chat**, **Set end-user token**, **Set page context**, **Send message**, **Prompt assistant**, **New conversation**.
* **States** — **is ready**, **is open**, **display mode**, **last error**.
* **Events** — **is ready**, **is opened**, **is closed**, **has an error**.

Build the context JSON with Bubble's `:formatted as JSON-safe` operator, for example `{"page":"invoice","invoiceId":<Current Page Invoice's unique id:formatted as JSON-safe>}`.

## Ground the assistant in the current page

By default the assistant only knows what the user types. Give it the **page context** — the record on screen, its id, the page name, a short excerpt — and it can act on it directly, for example passing a record id straight to a tool instead of asking for it:

```html
<script>
  FlowCPChat.setContext({
    page: 'project',
    projectId: 'abc123',
    projectName: 'Website redesign',
    status: 'in_review',
  });
</script>
```

* The context is a **flat object** of strings, numbers, booleans or `null` (no nested objects), up to **32 keys**, each value up to **2,000 characters**. Anything else is rejected.
* It is sent **with every turn** and appended to the system prompt as a clearly delimited **data block** — the model is told to treat the values as facts about the page, not as instructions.
* Call `setContext()` again whenever the user navigates (Bubble: a **Run javascript** action on page load or when the current record changes). Pass `null` to clear it.
* You can also set it up-front with a `data-context` attribute on the script tag: `data-context='{"page":"invoice","invoiceId":"inv_42"}'`.
* **Never put secrets or tokens in the context** — it is prompt text, not a credential channel. Use `setToken()` for the user's token.

If the widget [retains transcripts](#conversation-history), the latest context is stored with the conversation so your team can see which record a thread was about.

## Start the conversation from your app

Your app can decide when the assistant should step in — a user has been stuck on a step, a record is missing required fields, a plan is about to expire — and make it speak first, in context:

```js
// Open the panel and have the assistant write an opening message.
// The instruction is for the assistant only; the user never sees it.
FlowCPChat.setContext({ page: 'onboarding', step: 'connect-bank', attempts: 3 });
FlowCPChat.prompt(
  'The user has failed to connect their bank account three times. Offer help ' +
    'warmly, ask what error they are seeing, and mention that you can check the ' +
    'connection status for them.',
);
```

* `prompt(instruction)` opens the panel and streams the assistant's reply into the chat as its **first visible message**. The instruction itself is hidden from the user (it is part of the transcript the assistant sees, and appears marked as a **Host instruction** in retained transcripts).
* `sendMessage(text)` is the visible counterpart: it sends `text` **as if the user typed it** — handy for an "Ask the assistant about this" button next to a record.
* Both respect the widget's sign-in gate: when **Require sign-in before chatting** is on and no token has been provided, the call is ignored until the user connects (or you call `setToken()`). Call `setToken()` before `prompt()` when you have the token. With the gate off the call goes through — the assistant answers without the tools of any connector the user hasn't signed in to.
* The static **Greeting** is still shown before the first turn; `prompt()` simply adds the assistant's contextual message after it.

## Conversation history

By default the widget stores **nothing**: each turn is stateless, the transcript lives only in the visitor's browser tab, and it is gone on reload. If your product needs threads that **persist across pages and visits**, or your customer-success team needs to **read what users asked**, turn on **Retain transcripts** on the widget.

> **Conversation history is a paid feature** — it needs the **Team** plan or above, and each plan sets how long conversations may be kept (Team 30 days, Business 60, Enterprise 180). Chatting itself is on every plan, including **Starter**; only *keeping* the conversation is paid. On a plan without it, the **Retain transcripts** toggle is locked, the **Chat conversations** page and its sidebar entry are replaced by an upgrade card, the visitor's **History** list stays empty, and the widget keeps behaving as it does with retention off. See [Plans & features](/reference/plans-and-features.md).

With retention on:

* **Resumable threads.** The widget keeps a random, unguessable handle in the visitor's browser (per widget). When they navigate to another page of your app or come back later, the conversation is restored. The chat header gains a **New chat** button (and your app can call `FlowCPChat.reset()`) to start over.
* **The visitor's own history.** The chat header also gains a **History** button. It lists that visitor's past threads — when each was last active, how many messages it has, and a preview of their first question — and clicking one **reopens it and carries on in it**: the next message appends to the same thread rather than starting a new one. **New chat** no longer loses the previous thread; it moves it into the history list. History is **per browser**: it lists only the threads whose handle that browser still holds (up to 25), so it never enumerates other visitors' chats, and a thread belonging to a signed-in user stays hidden unless that user's token is present.
* **Bound to the signed-in user.** When the primary connector runs with an end-user token, the thread is bound to a hash of that token: it only resumes for a browser that presents the **same** user's token. A signed-out browser, or another user signing in on the same device, cannot read it — they get a fresh thread.
* **Retention window.** Set **Keep conversations for (days)** (default 30). How long you may keep them depends on the plan: **Team up to 30 days**, **Business up to 60**, **Enterprise up to 180**. Asking for more is bounded to the plan's ceiling, and older conversations are purged automatically. Moving to a shorter tier shortens the window of widgets you already configured — nothing to edit, and anything past the new window stops resuming at once.
* **Read them in the dashboard.** **Distribute → Chat conversations** lists every retained thread for the branch — see [Read chat conversations](/guides/read-chat-conversations.md). The entry appears in the sidebar only once a widget retains transcripts, and **View conversations** on the widget card opens it on that widget.

**What is stored — and what isn't.** A retained transcript is a **text-only projection**: the user's text, the assistant's text, the **names** of the tools the assistant called, how many attachments a message had, and the latest page context. FlowCP never stores attachment contents, tool **arguments or results** (they can carry your API's records), the model's reasoning, or the user's token. The visitor's handle and the user's token are stored only as SHA-256 hashes.

Turning retention **off** stops recording new turns but keeps what was already stored until it ages out or you **Delete all**.

**If your plan changes.** Downgrading to a plan without conversation history stops the recording immediately — every save, resume and history call re-checks the plan, so visitors get fresh threads, their **History** list comes back empty, and nothing new is written. The widget keeps its **Retain transcripts** setting (you can still switch it off), transcripts already stored are no longer readable in the dashboard, and the locked **Chat conversations** page still offers **Delete retained transcripts** so you can erase them. Upgrade again and the same widget resumes recording.

## Approving sensitive actions (human-in-the-loop)

When the assistant tries to run a **gated** tool — a destructive tool you've marked **Requires confirmation** (the DELETE-class opt-in), or a remote/third-party connector tool the server flags as **destructive** — the chat **pauses** and shows an **approval card** instead of running it. The card shows:

* the **tool** about to run and its exact **arguments**, and
* **whose access** it runs under — *your connected account* for user-context (OAuth) connectors, or the connector's *shared credentials* otherwise.

Click **Approve** to run it, or **Decline** to skip it. On decline, the assistant is told the action was declined and continues (it can explain or try something else). If the assistant queues **several** sensitive actions at once, they're shown together and run as one batch once you've decided each.

Approvals happen **in the browser**: nothing about a pending action is stored — declining or approving simply re-sends the conversation with your decision. The assistant can **never self-approve**; only your click runs a gated tool. Set which tools require confirmation on the [Tools page](/guides/review-and-customize-tools.md) (`DELETE` tools default to requiring it).

## Security & cost controls

* **Allowed origins** restrict which sites may embed the widget (enforced by the browser via `frame-ancestors`).
* **Rate limiting** caps requests per widget and per visitor, and your workspace **usage quota** is the hard ceiling on spend.
* **Attachments** are off by default and gated per widget by **Allow file attachments** and a per-file **size cap** — every part is re-validated server-side (allowed type, size, count) and a disabled widget rejects them.
* **Transcripts** are off by default (**Retain transcripts**) and need a paid plan (Team and above). When on, only a text projection is kept, bound to the signed-in user, for a bounded number of days — see [Conversation history](#conversation-history).
* **Sign-in** is enforced server-side: with **Require sign-in before chatting** on, a turn without the user's token is refused; with it off the turn runs but every connector that needs a per-user token stays closed — a shared credential is **never** substituted for a missing one.
* **Page context** (`setContext`) is bounded and injected as data, never as instructions; keep secrets out of it.
* **Disable** a widget at any time with the **Enabled** toggle — its snippet stops working immediately everywhere.
* **Rotate the public key** (the ↻ button) if a key is leaked; update your snippet afterward.

## Troubleshooting

* **The launcher doesn't appear.** Confirm the widget is **Enabled** and that the current site is listed under **Allowed origins**.
* **An inline widget shows the floating launcher instead.** The **Container selector** matched nothing on the page — check the browser console for the `inline chat container … was not found` error, and make sure the container element exists (and, if it's rendered by your framework, that it exists by the time the script runs).
* **The inline chat is the wrong height.** Set **Height (px)**, or give the container its own height — a sized container always wins.
* **"This chat widget is unavailable from this site."** The page's origin isn't in the widget's allowed origins.
* **Tools fail with an auth error.** The server needs an end-user token — turn on **Require end-user token** and call `FlowCPChat.setToken(...)`.
* **`FlowCPChat.prompt()` / `sendMessage()` does nothing.** The composer is gated on sign-in (**Require sign-in before chatting** is on, so the widget shows **Connect**). Provide the token first with `setToken()`, or let the user connect; the call is only skipped while the gate is up.
* **The assistant says it can't do something and asks the user to sign in.** That connector has no token for this visitor. Either let them connect from the **`+`** menu → **Connectors**, or turn on **Require sign-in before chatting** so nobody chats before signing in.
* **The conversation doesn't resume.** Check **Retain transcripts** is on and saved, and that your workspace is on the **Team** plan or above — retention stops as soon as a plan without conversation history takes effect. A thread bound to a signed-in user only resumes once that user's token is present again — the widget retries the resume as soon as the token arrives. Private-browsing modes that block `localStorage` start a fresh thread on every page.
* **The Bubble element shows nothing.** Check the **last error** state (or the browser console): a second FlowCP Chat element or a pasted snippet on the same page, an origin missing from **Allowed origins**, or a display mode the cached loader doesn't yet support. The **display mode** state tells you which mode actually rendered.
* **The assistant ignores the page context.** `setContext()` must be a flat object of primitives (no nested objects) with at most 32 keys; anything else is rejected and the turn runs without context. Check the browser console for a `data-context` parse error.

## Reacting to chat events

To have another system react when a visitor starts a conversation or sends a message, add a [webhook](/guides/webhooks.md) on the app and subscribe to `conversation.created` / `message.created`. The events are emitted after every completed turn, whether or not the widget retains transcripts.
