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

# Embeddable Chat Engine & Public Agent Surface

> The headless chat-core engine (transport seam, useChat, single SSE parser) and the anonymous slug-based public agent surface that powers /a/:slug today.

## Overview

There is **one chat engine**. The same headless core powers the in-app dashboard
chat, the public agent pages at `/a/:slug`, and the drop-in embed widget
(`/embed.js` → `/embed/:slug`). The engine lives in `packages/chat-core` — pure TypeScript plus a single
React hook, with **zero** Next.js, axios, or app-context coupling. UI is layered
on top; the engine itself owns only state, transport, and SSE parsing.

<Note>
  **What is shipped today:** `chat-core`, the anonymous public agent pages
  (`/a/:slug`), the **drop-in `<script>` embed widget** (`/embed.js` →
  floating launcher → iframe of `/embed/:slug`, configured in the agent's
  **Channels → Embed Widget** tab), and the **anonymous operator-presence SSE
  channel**. Public chat works front-end-only against the anonymous surface; no
  API key is involved.
</Note>

## Drop-in embed widget

`GET /embed.js` (served by the web app) is a dependency-free loader. A site owner
pastes:

```html theme={null}
<script
  src="https://app.brainstormer.io/embed.js"
  data-slug="your-agent-slug"
  data-color="#3B39A7"
  data-position="right">
</script>
```

It injects a floating launcher button that toggles an iframe of `/embed/:slug` —
a chrome-minimal version of the public chat page (no back-link/URL sync) that
mounts the same `EnhancedChatInterface` in `publicSlug` mode. `data-color` and
`data-position` (`right`/`left`) are optional; on mobile the panel goes
full-screen. The loader is idempotent (`window.__bsioWidget[slug]`). Agents that
`requireLogin` are not embeddable anonymously and render a short notice instead.

## Host page events (iframe → parent)

The embedded chat emits a small set of **one-way** events to the page that frames
it, so host pages can build their own gating — login walls, paywalls, usage
analytics — entirely on their side. The widget never listens for commands back;
all gating logic lives with the host.

Events are delivered via `window.parent.postMessage`. Every event is a namespaced
envelope so hosts can filter out unrelated `message` traffic (extensions, other
widgets, devtools):

```ts theme={null}
{
  source: "brainstormer-widget",   // always — filter on this
  v: 1,                            // envelope version
  type: "message:sent",            // see table below
  seq: 3,                          // monotonic per session
  ts: "2026-06-17T10:04:01.244Z",  // ISO timestamp
  agentSlug: "your-agent-slug",
  conversationId: "…" | null,
  role: "user" | "assistant",      // message:* only
  // running session totals (message:* only):
  userMessageCount: 2,
  assistantMessageCount: 1,
  messageCount: 3,
}
```

| Event                  | Fires when…                               | Key fields                                                                                          |
| ---------------------- | ----------------------------------------- | --------------------------------------------------------------------------------------------------- |
| `widget:ready`         | the chat has mounted inside the iframe    | `agentSlug`, `conversationId` (null until an existing one loads)                                    |
| `conversation:started` | a brand-new conversation is created       | `conversationId`                                                                                    |
| `conversation:resumed` | an existing conversation finishes loading | `conversationId`, `userMessageCount`, `assistantMessageCount`, `messageCount`                       |
| `widget:readonly`      | read-only mode is turned on or off        | `readOnly` (boolean), `conversationId`, `userMessageCount`, `assistantMessageCount`, `messageCount` |
| `message:sent`         | the visitor sends a message               | `role:"user"`, `userMessageCount`, `messageCount`                                                   |
| `message:received`     | an assistant reply completes              | `role:"assistant"`, `assistantMessageCount`, `messageCount`                                         |
| `widget:error`         | a chat request fails                      | `message`                                                                                           |

**Payloads carry metadata and counts only — never message text.** Counts are
running totals for the conversation. When an existing conversation is resumed —
e.g. a returning visitor whose session is restored, or a `conversationId` loaded
from history — the counts seed from the loaded message history and continue from
there, so a gating threshold (login/payment wall) carries across page reloads
instead of restarting at zero. A brand-new conversation starts the counts at
zero on first load.

<Note>
  `widget:ready` fires the moment the iframe mounts — **before** an existing
  conversation has loaded — so its `conversationId` is `null` and it carries no
  counts. To re-apply a wall on reload, listen for **`conversation:resumed`**,
  which fires once the existing conversation hydrates and carries its
  `userMessageCount` / `assistantMessageCount` / `messageCount`. (Subscribing to
  `"*"` catches it too.)
</Note>

### Consuming via the loader (`embed.js`)

When you embed with `<script src="…/embed.js">`, the loader exposes a global
`BrainstormerWidget` with a subscribe API (it verifies `event.origin` against the
widget origin for you):

```js theme={null}
// Gate after 5 user messages
BrainstormerWidget.on("message:sent", function (e) {
  if (e.userMessageCount >= 5) showLoginWall();
});

// Subscribe to everything; on() returns an unsubscribe function
const off = BrainstormerWidget.on("*", (e) => console.log(e.type, e));
```

### Consuming a raw `<iframe>` embed

If you embed `/embed/:slug` (or `/a/:slug/chat`) as your own `<iframe>` without
the loader, listen for `message` directly and verify the origin yourself:

```js theme={null}
window.addEventListener("message", (e) => {
  if (e.origin !== "https://app.brainstormer.io") return;
  const evt = e.data;
  if (!evt || evt.source !== "brainstormer-widget") return;
  if (evt.type === "message:received" && evt.assistantMessageCount >= 3) {
    showPaywall();
  }
});
```

<Warning>
  These counts are a **client-side UX signal, not a security boundary** — a
  determined visitor can reload, edit the iframe, or call the public chat API
  directly. Use the events to drive the *prompt* (nudge to log in / pay), but
  enforce the actual limit **server-side** as well (e.g. a per-`anonymousId`
  message budget on the public chat endpoint).
</Warning>

### Read-only mode

Read-only mode renders the conversation but **hides the composer**, so the host
can overlay its own wall (login/payment) on top of the iframe — or show a
shared, read-only view of a conversation. It is the one place the host talks
*into* the widget; everything else is one-way.

There are two ways to activate it:

* **On load** — start locked by adding `?readonly=1` (or `?mode=readonly`) to the
  iframe URL, or `data-readonly="true"` on the embed `<script>`. Useful for a
  shared read-only link.
* **At runtime** — toggle it live from your gating logic:

```js theme={null}
// Lock the chat after the visitor hits your limit…
BrainstormerWidget.on("message:sent", (e) => {
  if (e.userMessageCount >= 5) BrainstormerWidget.setReadOnly(true);
});

// …and unlock once they sign in.
BrainstormerWidget.setReadOnly(false);
```

The widget posts a `widget:readonly` event (carrying the current `readOnly` flag
and counts) whenever the mode flips, so you can sync your overlay. On reload, pair
`?readonly=1` with the `conversation:resumed` event to re-apply gating with the
correct counts.

Raw `<iframe>` embedders (no loader) can post the command directly to the iframe:

```js theme={null}
iframe.contentWindow.postMessage(
  { source: "brainstormer-host", type: "set-readonly", readOnly: true },
  "https://app.brainstormer.io",
);
```

<Warning>
  Read-only mode is a **UX affordance, not an entitlement check**. It only hides
  the composer client-side; a visitor can still reach the public chat API
  directly, so enforce real limits server-side as above.
</Warning>

## Architecture

```mermaid theme={null}
graph TD
    UC["useChat hook<br/>(state, streaming, pagination)"] --> T{ChatTransport seam}
    T -->|JWT| AT["AuthTransport<br/>/bots/:id/chat"]
    T -->|slug + anonymousId| PT["PublicTransport<br/>/public/agents/:slug/chat"]
    AT --> SSE["parseSSEStream()<br/>(the ONE parser)"]
    PT --> SSE
    AT --> DASH["Dashboard chat (in-app)"]
    PT --> PUB["Public page /a/:slug + embed widget"]
```

## The `chat-core` package

`@brainstormer/chat-core` is a headless workspace package (`packages/chat-core/`).
It exports types, the transport interface and two implementations, the `useChat`
hook, the SSE parser, a pluggable storage adapter, and anonymous-id helpers.

### The `ChatTransport` seam

`ChatTransport` (`src/transport.ts`) is the interface `useChat` talks to. The
implementation decides which endpoints and auth to use; `useChat` itself is
transport-agnostic.

```ts theme={null}
interface ChatTransport {
  createConversation(input?): Promise<CreatedConversation>;
  sendMessage(input, handlers, signal?): Promise<void>; // streams over SSE
  uploadAttachment(file, opts?): Promise<AttachmentRef>;
  loadMessages(conversationId, page?): Promise<ChatMessage[]>;
  listConversations(page?): Promise<ChatConversation[]>;
  subscribeEvents(conversationId, handlers): Unsubscribe;
}
```

Two implementations, one engine:

| Transport         | Auth                 | Endpoints                          | Powers                          |
| ----------------- | -------------------- | ---------------------------------- | ------------------------------- |
| `AuthTransport`   | JWT (`Bearer`)       | `/bots/:id/chat`, `/conversations` | Dashboard chat (in-app)         |
| `PublicTransport` | slug + `anonymousId` | `/public/agents/:slug/*`           | Public `/a/:slug` page + widget |

`AuthTransport` scopes attachment uploads by `organizationId` and subscribes to
the JWT-authenticated operator-presence/HITL SSE channel
(`/conversations/:id/events?token=`). `PublicTransport` carries no secret — the
visitor's `anonymousId` scopes everything — and its `subscribeEvents` opens the
**anonymous presence channel** `GET /public/agents/:slug/conversations/:id/events?anonymousId=`
(EventSource), which streams the same operator-presence / HITL events to public
visitors. The bot validates that the `anonymousId` owns the conversation; the
gateway raw-pipes the SSE.

### The `useChat` hook

`useChat(options)` (`src/use-chat.ts`) is the headless engine. It owns messages,
streaming buffer, input, conversation lifecycle, history, and error/loading
state, and guarantees **thread isolation**:

* **Per-send `AbortController`** — switching or closing the conversation, or
  unmounting, aborts the in-flight stream. State writes are gated on
  `!aborted && mounted`.
* **Synchronous in-flight ref** — blocks an overlapping send within the same
  tick.
* **Paged history** — `loadMessages` is paged (default 50); `loadMore()` prepends
  older pages and `hasMore` reflects remaining history.

Authorization and ownership are enforced server-side; the transport carries only
the scoping token (JWT or `anonymousId`).

### One SSE parser

`parseSSEStream(response, handlers, signal?, limits?)` (`src/sse.ts`) reads a
`text/event-stream` body and dispatches `token` / `done` / `error` events. The
wire format (from the bot service) is one `data: {json}` line per event:

```
data: {"type":"token","content":"Hello"}
data: {"type":"done","response":{ ...ChatResponse }}
data: {"type":"error","message":"...","code":"..."}
```

Parser rules:

* Malformed lines are skipped.
* A `done` payload is forwarded only when it is a well-formed `ChatResponse`
  (string `response` + `conversationId`).
* An aborted stream ends without a terminal `error`.
* Streams are capped at `maxTotalChars` (\~1MB) and `maxLineChars` (\~256KB).

### Attachment upload

`transport.uploadAttachment(file)` returns an `AttachmentRef` (`{ id,
accessToken }`) to pass back in the next send. `AuthTransport` posts to the
org-scoped `/attachments/upload`; `PublicTransport` posts to the anonymous
`/public/agents/:slug/attachments?anonymousId=` endpoint. Both responses are
mapped to the same `{ id, accessToken }` shape.

### Storage & anonymous id

`src/storage.ts` provides a pluggable `StorageAdapter` (memory / Web Storage)
plus `namespacedStorage(adapter, namespace)` — every key is prefixed with
`${namespace}:` so two embeds or agents on one host page can't collide on storage
keys. `src/ids.ts` mints a per-visitor `anonymousId` UUID (`createAnonymousId` /
`getOrCreateAnonymousId`) via the Web Crypto RNG. Each distinct visitor **must**
get its own id — the server validates conversation ownership against it, so
reusing one id across visitors would let them read each other's threads.

## The public agent surface (`/a/:slug`)

The public pages (`apps/web/src/app/(public)/a/[slug]/`) render the shared
`EnhancedChatInterface` with a `publicSlug` prop. When set, calls route to the
anonymous endpoints — no auth, no API key. The backing routes live in
`services/bot/src/routes/distribution.routes.ts`:

| Method   | Route                                             | Purpose                                       |
| -------- | ------------------------------------------------- | --------------------------------------------- |
| `GET`    | `/public/agents/:slug`                            | Agent metadata + widget config (no prompt)    |
| `POST`   | `/public/agents/:slug/conversations`              | Create conversation (returns welcome message) |
| `POST`   | `/public/agents/:slug/chat`                       | Chat (SSE when `stream` + `streamingEnabled`) |
| `GET`    | `/public/agents/:slug/conversations`              | List the visitor's conversations              |
| `GET`    | `/public/agents/:slug/conversations/:id/messages` | Paged message history                         |
| `PATCH`  | `/public/agents/:slug/conversations/:id`          | Rename a conversation                         |
| `DELETE` | `/public/agents/:slug/conversations/:id`          | Delete a conversation                         |
| `POST`   | `/public/agents/:slug/attachments`                | Anonymous file upload                         |

Every anonymous route requires a valid `anonymousId` (UUID) and 401s if the
agent's distribution has `requireLogin`. Ownership is enforced by matching the
conversation's stored `anonymousId` **and** `botId` before any read/rename/delete
— a mismatch returns 403 (IDOR guard). The continue-conversation chat path
applies the same check before writing.

### Anonymous file upload (hardened)

`POST /api/public/agents/:slug/attachments?anonymousId=<uuid>` (multipart, field
`file`) is the anonymous upload path. It returns the same shape as the
authenticated endpoint so the shared `FileUpload` component maps both
identically:

```json theme={null}
{
  "attachment": {
    "id": "uuid",
    "accessToken": "...",
    "fileType": "image",
    "originalFilename": "photo.png",
    "fileSizeBytes": 12345,
    "contentType": "image/png",
    "processingStatus": "queued",
    "createdAt": "..."
  },
  "processingQueued": true
}
```

Pass `{ id, accessToken }` back in the chat send. The bot looks the attachment up
by `(id, accessToken)` within the agent's org; the `accessToken` is a per-upload
secret returned only to the uploader. Constraints:

* **Gating** — 403 unless the agent has `fileUploadsEnabled`; 401 if the agent
  requires login; 400 without a valid `anonymousId` UUID.
* **Content validation** — `FileUploadService.validateFile()` enforces the size
  cap and MIME allowlist, then magic-byte sniffs the bytes and rejects a file
  whose content doesn't match its declared type (e.g. markup served as an image).
* **Rate limiting** (gateway) — per-IP and per-`anonymousId` fixed windows →
  `429` + `Retry-After`.
* **Multipart** — the gateway pipes the raw request to the bot service, so the
  multipart boundary is preserved (no `Content-Type` should be set manually on
  the client `FormData`).

## Brand token contract

Components in `apps/web/src/components/chat/` read `var(--brand-*)` CSS custom
properties with a platform-default fallback (e.g. `var(--brand-surface, #ffffff)`)
and must not hardcode color, spacing, radius, border width, or font family. The
same chat UI renders on creator-branded public pages and inside the embed widget
from these tokens.

## Roadmap

The reusable engine, the public surface, the drop-in embed widget, and the
anonymous presence channel are all live. Still ahead: a `provision`-from-URL
entry point inside the widget (build-an-agent-from-a-URL flow embedded directly),
richer operator-presence UI (typing indicators), and additional channels
(WhatsApp / Telegram / Email — currently "Coming Soon" in the Channels tab). The
full design and phasing are tracked in `docs/architecture/EMBEDDABLE_CHAT_WIDGET.md`.

## Related

* [Anonymous Chat API](/api-reference/provisioning/anonymous-chat) — the public
  slug-based endpoints in full.
* [Agent Provisioning](/developer/architecture/agent-provisioning) — the
  server-side "build an agent from a URL" path.
* [Chat Attachments & RAG](/developer/architecture/chat-attachments) — how an
  uploaded attachment flows into RAG and the LLM call.
