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.
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.Drop-in embed widget
GET /embed.js (served by the web app) is a dependency-free loader. A site owner
pastes:
/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 viawindow.parent.postMessage. Every event is a namespaced
envelope so hosts can filter out unrelated message traffic (extensions, other
widgets, devtools):
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.
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.)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):
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:
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, ordata-readonly="true"on the embed<script>. Useful for a shared read-only link. - At runtime — toggle it live from your gating logic:
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:
Architecture
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.
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 —
loadMessagesis paged (default 50);loadMore()prepends older pages andhasMorereflects remaining history.
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:
- Malformed lines are skipped.
- A
donepayload is forwarded only when it is a well-formedChatResponse(stringresponse+conversationId). - An aborted stream ends without a terminal
error. - Streams are capped at
maxTotalChars(~1MB) andmaxLineChars(~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:
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:
{ 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 validanonymousIdUUID. - 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-
anonymousIdfixed windows →429+Retry-After. - Multipart — the gateway pipes the raw request to the bot service, so the
multipart boundary is preserved (no
Content-Typeshould be set manually on the clientFormData).
Brand token contract
Components inapps/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: aprovision-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 — the public slug-based endpoints in full.
- Agent Provisioning — the server-side “build an agent from a URL” path.
- Chat Attachments & RAG — how an uploaded attachment flows into RAG and the LLM call.

