Skip to main content

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

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

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.
Two implementations, one engine: 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 historyloadMessages 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:
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: 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:
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 validationFileUploadService.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.