Back to docs
Guide 06

Widget Integration

The embeddable widget is a separate service (packages/widget, port 3211) that exposes an AI chat on your website. Architecture: Express + Preact IIFE in a sandboxed iframe, a transparent SSE proxy toward the server, anonymous sessions, lead capture, per-…

5 min read

The embeddable widget is a separate service (packages/widget, port 3211) that exposes an AI chat on your website. Architecture: Express + Preact IIFE in a sandboxed iframe, a transparent SSE proxy toward the server, anonymous sessions, lead capture, per-site branding.

The widget is an Enterprise feature (license widget_enabled); Community has 1
widget by default but creating one requires the flag to be active.

How to embed (quick start)

  1. Create the widget (admin UI → Settings → Widgets, or API):
http
POST /api/widgets
Authorization: Bearer <admin JWT>
{ "name": "Company Site", "allowedOrigins": ["https://www.company.com"],
  "workspaceIds": ["<uuid>"], "primaryColor": "#4c6ef5", "botName": "Assistant" }

Response: { widget } with widget.id.

  1. Whitelist the knowledge base - PUT /api/widgets/:id/workspaces with the UUIDs of the workspaces whose RAG the widget can access (WidgetWorkspace table, composite PK [widgetId, workspaceId]).
  1. Insert the snippet into the host page:
html
<div id="simmetric-widget" data-widget-id="<widget.id>" data-position="bottom-right"></div>
<script
  src="https://widget.yourdomain.com/widget/<widget.id>.js"
  integrity="sha384-<HASH>"
  crossorigin="anonymous"
  async
></script>
Security: if you serve the loader from a third-party CDN, generate the SHA-384 hash
of the bundle and add integrity + crossorigin="anonymous" (Subresource Integrity).
If the loader is same-origin with your widget service, SRI is optional but recommended
to defend against CDN/asset compromise.

The loader (packages/widget/src/routes/loader.ts) reads the data-* attributes, creates an <iframe sandbox="allow-scripts allow-forms"> (no allow-same-origin, no allow-top-navigation), loads /widget/<id> (HTML) which mounts the Preact bundle /widget/app.js.

Branding & trigger configuration

Fields of the Widget model (validated by widget.schema.ts in shared):

FieldDefaultDescription
primaryColor#4c6ef5Accent color (hex regex)
botNameAI AssistantBot name
logoUrl / avatarUrl-Image URL (http/https)
welcomeMessage / fallbackMessage-Initial/fallback messages (per-locale via localizedTexts)
positionbottom-rightbottom-right/bottom-left
allowedOrigins-URL array (JSON, max 2000 chars) for dynamic CORS
autoOpenDelay-Auto-open seconds (null = disabled, 1–300)
autoOpenUrlPatterns-Glob patterns (**,*,?,{a,b}) for auto-open on URLs
exitIntentEnabledfalseOpen on exit intent
exitIntentCooldownMs180000060000–86400000 ms
leadCaptureEnabledfalseLead capture
leadCapturePrompt-Lead prompt (max 500)
rateLimitPerMinute / sessionLimitPerDay-Per-widget limit overrides (null = global)
responseProviderId / responseModel-Response model pin (v0.22, see below)
suggestedQuestions / credits-Per-locale suggestions (max 10) / widget credits

URL query params (?primaryColor=...&position=...&locale=...) override at runtime. Additional v0.22 fields: rateLimitPerMinute / sessionLimitPerDay (per-widget overrides of the global limits; null = global default) and responseProviderId/responseModel.

Response model pin (v0.22): each widget's admin configuration can select the LLM provider + model that serves that widget's chat responses (responseProviderId, a provider UUID; responseModel, the model name). The pin is resolved server-side from the widget DB row on every chat request (never client-supplied - the widget proxy and the visitor UI are untouched), takes priority over the workspace default, and widgets without a pin keep the existing workspace/global resolution chain. Widget chats record the model that actually serves them.

Triggers (widget/hooks/useTriggers.ts): listen for postMessage from the loader - simmetric:urlChange (checks autoOpenUrlPatterns via matchUrlPattern/globToRegex: **, *, ?, {a,b}), simmetric:exitIntent (opens with cooldown). Exit intent is detected loader-side via mouseleave with e.clientY <= 10. autoOpenDelay is in seconds (1–300, null = disabled); exitIntentCooldownMs is 60000–86400000 ms.

Authentication & API key

  • Widget ↔ Server: header X-Api-Key: <WIDGET_API_KEY> toward /api/internal/widget/*. The key must match between the root .env (widget and server read the same file).
  • Visitor ↔ Widget service: header x-session-token (anonymous session, not JWT).
  • Dynamic CORS (widgetCors.ts): per-origin CORS headers based on allowedOrigins. Fail-closed on DB error (no header = browser blocks).

Anonymous sessions

POST /api/sessions (widget) → the server creates a WidgetSession: 256-bit hex token (crypto.randomBytes(32)), 24h expiry, logged IP. sessionMiddleware validates the token on every chat request and attaches req.widgetSession + req.widgetConfig. The session is persisted into the host's sessionStorage via the postMessage handshake with the loader (simmetric:storage-*) and reused across reloads; on expiry → 401 → the Preact UI shows "Session expired" and a page refresh lets the widget recreate the session.

Chat flow (SSE proxy)

text
Visitor → POST /api/chat/:widgetId/stream  (x-session-token, body {message, chatId?})
→ widgetDailyMessageLimiter (per widget+IP, 5/24h prod, sessionLimitPerDay override)
→ widgetChatLimiter (per widgetId, 30/min prod, rateLimitPerMinute override)
→ sessionMiddleware validates the session
→ check hourlyRemaining (DB-tracked, 20/h) → 429 when exhausted
→ increment messageCount (fire-and-forget)
→ RAG pre-search: POST /api/internal/widget/search {query, widgetId}
   the server resolves workspaceIds from the WidgetWorkspace whitelist (IDOR prevention)
→ proxy to SERVER_URL/api/internal/widget/chat/stream
   headers: X-Api-Key, X-Widget-Id, X-Widget-Session-Id, X-Accel-Buffering: no
→ verbatim byte SSE relay (responseType: "stream", filters event: thinking)
→ Preact useWidgetChat parse: token | citations | done | error

The proxy is transparent: it does not parse/filter/transform SSE events. If the RAG pre-search fails, it emits status: rag-degraded but the chat continues without context.

Lead capture

POST /api/lead/:widgetId (widget, sessionMiddleware):

  • Checks config.leadCaptureEnabled (403 when off).
  • Body widgetLeadSubmitSchema: email (required), name (optional), transcript[] (role/content/timestamp).
  • submitLead(widgetId, email, name, transcript, session.id) - passes session.id (UUID FK).
  • Limiter 3 leads/hour per IP.
  • Admin retrieves: GET /api/widgets/:id/leads, CSV export GET /api/widgets/:id/leads/export (with date filter + column selection; since v0.22 always available - the lead_export commodity flag was removed).

Rate limit (double layer)

LimiterScopeProd limitWindow
widgetDailyMessageLimiterper widget+IP5/day (sessionLimitPerDay override)24h
widgetChatLimiterper widgetId30/min (rateLimitPerMinute override)1 min
widgetSessionLimiterper IP50/day (session creations)24h
widgetLeadLimiterper IP3/hour1h
server hourlyRemainingper session20/h1h
server dailyRemainingper session5/24h24h

Security

  • iframe sandbox allow-scripts allow-forms (no same-origin, no top-navigation).
  • Relaxed helmet for embedding (no CSP, no frameguard, no COOP/COEP/CORP).
  • Messages rendered via markdown-it + dompurify (anti-XSS).
  • Server-side DLP: each run's DLP matches travel in the SSE done event (dlp_matches, only when DLP is active and no role bypass); visitor PII consent is handled by the PIIWarningPrompt (pre-chat gate, per-locale piiConsent texts).
  • No cookies, no localStorage from the sandboxed iframe (storage via the postMessage handshake with the loader, batched on done/error/unmount).
  • The loader uses getAttribute() (no innerHTML) + encodeURIComponent; inbound simmetric:* messages are validated with the event.source === iframeEl.contentWindow guard.
  • The cache-bust endpoint (POST /api/config/:widgetId/cache-bust) compares X-Api-Key in constant time (crypto.timingSafeEqual with a length guard, fail-closed when unset).

Per-widget model pin & prompt (F-WIDGET-1-class)

A widget can pin its own model: the Widget row carries responseProviderId + responseModel (string max 200, nullable). Server-side only: the widget proxy strips any client-supplied providerId/model (tamper pin, routes/internalWidget.ts:287-306); the pin is threaded as the 6th arg from the DB row (:336-352). Unset columns (null) fall through to the existing workspace default → global default → ENV chain.

Per-widget system prompt: admin-authored, max 4000 chars, resolved from the DB; blank/null → passes through resolveWidgetSystemPrompt to the module-private grounding floor (an unpinned unprompted widget stays grounded); the reply-language directive is composed downstream by the orchestrator from the visitor locale. Write contract: null clears the pin.

MCP (indirect)

The widget does not manage MCP: install/unpin happens in the server admin. Linked workspaces can have MCP tools installed → the agent uses them during widget chats automatically (zero widget code). mcpSources in the SSE done event indicates which MCP connections contributed.

Extending

  • New component → packages/widget/src/widget/components/ (Preact + Tailwind, no shadcn/ui).
  • New trigger → extend useTriggers.ts + a Widget field + the internalWidget.ts route.
  • New rate limit → src/middleware/rateLimit.ts + mount in index.ts.
  • Themed CSS → --widget-* namespace in src/widget/index.css; statics with Tailwind utilities.

Key files

FileRole
packages/widget/src/index.tscreateApp() factory, route mount
src/routes/loader.tsLoader JS + iframe HTML
src/routes/chat.tsSSE proxy + RAG pre-search
src/routes/session.tsAnonymous session
src/routes/config.tsConfig (5 min cache)
src/routes/lead.tsLead capture
src/middleware/session.tssessionMiddleware
src/widget/App.tsxPreact root, hook coordination
src/widget/hooks/{useWidgetChat,useWidgetConfig,useTriggers}.tsState