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-…
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 1widget by default but creating one requires the flag to be active.
How to embed (quick start)
- Create the widget (admin UI → Settings → Widgets, or API):
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.
- Whitelist the knowledge base -
PUT /api/widgets/:id/workspaceswith the UUIDs of the workspaces whose RAG the widget can access (WidgetWorkspacetable, composite PK[widgetId, workspaceId]).
- Insert the snippet into the host page:
<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 addintegrity+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):
| Field | Default | Description |
|---|---|---|
primaryColor | #4c6ef5 | Accent color (hex regex) |
botName | AI Assistant | Bot name |
logoUrl / avatarUrl | - | Image URL (http/https) |
welcomeMessage / fallbackMessage | - | Initial/fallback messages (per-locale via localizedTexts) |
position | bottom-right | bottom-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 |
exitIntentEnabled | false | Open on exit intent |
exitIntentCooldownMs | 1800000 | 60000–86400000 ms |
leadCaptureEnabled | false | Lead 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 onallowedOrigins. 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)
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 | errorThe 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)- passessession.id(UUID FK).- Limiter 3 leads/hour per IP.
- Admin retrieves:
GET /api/widgets/:id/leads, CSV exportGET /api/widgets/:id/leads/export(with date filter + column selection; since v0.22 always available - thelead_exportcommodity flag was removed).
Rate limit (double layer)
| Limiter | Scope | Prod limit | Window |
|---|---|---|---|
| widgetDailyMessageLimiter | per widget+IP | 5/day (sessionLimitPerDay override) | 24h |
| widgetChatLimiter | per widgetId | 30/min (rateLimitPerMinute override) | 1 min |
| widgetSessionLimiter | per IP | 50/day (session creations) | 24h |
| widgetLeadLimiter | per IP | 3/hour | 1h |
| server hourlyRemaining | per session | 20/h | 1h |
| server dailyRemaining | per session | 5/24h | 24h |
Security
- iframe sandbox
allow-scripts allow-forms(no same-origin, no top-navigation). - Relaxed
helmetfor 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
doneevent (dlp_matches, only when DLP is active and no role bypass); visitor PII consent is handled by thePIIWarningPrompt(pre-chat gate, per-localepiiConsenttexts). - No cookies, no localStorage from the sandboxed iframe (storage via the
postMessagehandshake with the loader, batched on done/error/unmount). - The loader uses
getAttribute()(noinnerHTML) +encodeURIComponent; inboundsimmetric:*messages are validated with theevent.source === iframeEl.contentWindowguard. - The cache-bust endpoint (
POST /api/config/:widgetId/cache-bust) comparesX-Api-Keyin constant time (crypto.timingSafeEqualwith 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+ aWidgetfield + theinternalWidget.tsroute. - New rate limit →
src/middleware/rateLimit.ts+ mount inindex.ts. - Themed CSS →
--widget-*namespace insrc/widget/index.css; statics with Tailwind utilities.
Key files
| File | Role |
|---|---|
packages/widget/src/index.ts | createApp() factory, route mount |
src/routes/loader.ts | Loader JS + iframe HTML |
src/routes/chat.ts | SSE proxy + RAG pre-search |
src/routes/session.ts | Anonymous session |
src/routes/config.ts | Config (5 min cache) |
src/routes/lead.ts | Lead capture |
src/middleware/session.ts | sessionMiddleware |
src/widget/App.tsx | Preact root, hook coordination |
src/widget/hooks/{useWidgetChat,useWidgetConfig,useTriggers}.ts | State |
Cross-link
- Widget endpoints: 04 - API Reference
- RAG/agent internals: 05 - Feature Guide