Widget Technical Knowledge¶
This document provides a complete technical overview of the Rose Widget (frontend/widget/), the embeddable chat interface for the Website Agent. For the development workflow and testing guide, see Widget Development Setup.
Table of Contents¶
- Overview
- Folder Structure
- Build System
- UI Modes & State Management
- Supabase Configuration
- Mobile Handling
- Shadow DOM Implementation
- Loader System
- Widget Public API
- Code Patterns & Architecture
- Analytics Integration
- Development Workflow
- CDN Infrastructure
- Storage and Persistence
- Visitor Enrichment
- API Endpoints
- Key Files Quick Reference
- Troubleshooting
Overview¶
The Rose Widget is a self-contained, embeddable chat widget built with React that provides conversational AI capabilities on B2B websites. Key characteristics:
- Zero external dependencies - React is bundled or loaded from CDN
- Shadow DOM isolation - Complete CSS isolation from host websites
- Two-file deployment - Loader (
rose-loader.js) + Widget (inboundx-widget.js) - Multi-platform support - Works as standalone widget, Chrome extension, or preprod testing UI
Architecture Overview¶
User Website
│
├── rose-loader.js (universal loader)
│ ├── Loads React from CDN if needed
│ ├── Loads widget bundle (CSS is inlined)
│ └── Initializes widget with config
│
└── inboundx-widget.js (main bundle, ~1.3MB uncompressed)
├── Shadow DOM container
├── React application
│ ├── SiteConfigProvider (Supabase config)
│ ├── AnalyticsProvider (PostHog)
│ ├── DisplayController (visibility logic)
│ └── RoseWidget (main UI)
└── CSS styles (inlined in bundle)
Folder Structure¶
frontend/widget/
├── src/ # TypeScript/React source code
│ ├── main.tsx # Main entry - InboundXWidgetManager class
│ ├── loader.ts # Rose Loader entry point
│ ├── styles.css # Tailwind + Shadow DOM overrides
│ ├── fonts.css # Font-face declarations
│ ├── components/
│ │ └── DisplayController.tsx # Display decision orchestrator
│ ├── config/
│ │ └── deployment.ts # Deployment target configuration
│ ├── lib/widgetInit/
│ │ ├── index.ts # Widget initialization exports
│ │ ├── initializationCheck.ts # Traffic control + display rules
│ │ └── trafficControl.ts # Gradual rollout via Supabase
│ ├── types/
│ │ └── globals.d.ts # Global type definitions
│ └── __tests__/ # Widget-specific tests
│
├── build-src/
│ └── inline-loader.js # Inline loader build template
│
├── scripts/
│ ├── build-demo.cjs # Demo page generation
│ ├── update-safelist.cjs # Tailwind class extraction
│ └── extract-tailwind-classes.cjs # Class detection utility
│
├── dist/ # Build output (generated)
│ ├── inboundx-widget.js # Main widget bundle with CSS inlined (~1.3MB, ~350KB gzipped)
│ ├── inboundx-widget.js.map # Source map for debugging
│ ├── rose-loader.js # Universal loader (~5KB)
│ └── fonts/ # Satoshi Variable font files
│
├── Configuration Files
│ ├── vite.config.ts # Main widget build config
│ ├── vite.loader.config.ts # Loader build config
│ ├── tsconfig.json # TypeScript config
│ ├── tailwind.config.cjs # Tailwind with auto-safelist
│ ├── postcss.config.cjs # PostCSS pipeline
│ ├── package.json # Dependencies and scripts
│ ├── justfile # Task automation
│ └── cors-config.json # CDN CORS configuration
│
└── HTML Templates
├── index.html # Dev server entry
├── client-production.template.html
├── mobile-detection-demo.html
└── maintenance.html
Key File Responsibilities¶
| File | Purpose |
|---|---|
| src/main.tsx | InboundXWidgetManager singleton - handles Shadow DOM creation, CSS injection, React mounting, and public API (init, destroy, getWidget) |
| src/loader.ts | Universal loader for GTM/plain HTML - loads React dependencies, widget assets, handles GTM events |
| src/components/DisplayController.tsx | Orchestrates display decision: analytics ready → traffic control → display rules → render |
| src/lib/widgetInit/trafficControl.ts | Deterministic bucketing based on PostHog distinct_id for gradual rollout |
Build System¶
Entry Points¶
- Widget Build:
src/main.tsx→dist/inboundx-widget.js(UMD) - Loader Build:
src/loader.ts→dist/rose-loader.js(UMD)
Build Pipeline¶
npm run build (or just build)
├── prebuild: update-safelist.cjs # Auto-extract Tailwind classes
├── type-check: tsc --noEmit # TypeScript validation
├── vite build (vite.config.ts)
│ ├── Process main.tsx with React + Tailwind
│ ├── CSS inlined via ?inline import (no separate CSS file)
│ ├── esbuild minification
│ ├── Copy fonts to dist/fonts/
│ └── Output: inboundx-widget.js (CSS bundled inside)
└── vite build (vite.loader.config.ts)
├── Process loader.ts as standalone
├── Terser minification (drops console in production)
└── Output: rose-loader.js
Build Commands¶
# From widget directory
just build # Build widget + loader (production)
just build-debug # Build with debug logging enabled
just dev # Build debug + serve on localhost:8083
# From frontend directory
just build-widget # Build widget only
Vite Configuration Highlights¶
vite.config.ts (Widget):
build: {
lib: {
entry: 'src/main.tsx',
name: 'InboundXWidget',
formats: ['umd'],
},
rollupOptions: {
output: { exports: 'named' }
},
cssCodeSplit: false, // CSS bundled into JS (no separate file)
minify: 'esbuild',
esbuild: {
drop: process.env.NODE_ENV === 'production' ? ['debugger'] : [],
// Tree-shake debug logs (logger.error is NOT pure - sends to Sentry)
pure: ['logger.debug', 'logger.info', 'logger.log', 'logger.warn', 'logger.trace'],
treeShaking: true,
},
}
vite.loader.config.ts (Loader):
build: {
lib: {
entry: 'src/loader.ts',
name: 'InboundXLoader',
formats: ['umd'],
},
minify: 'terser',
terserOptions: {
compress: {
drop_console: process.env.NODE_ENV === 'production', // Only in production
drop_debugger: true,
passes: 2,
},
mangle: {
safari10: true,
reserved: ['InboundXLoader', 'init', 'destroy'],
},
},
}
UI Modes & State Management¶
System-Level Display Modes¶
Controlled by DisplayController.tsx based on configuration in Supabase:
| Mode | Configuration | Behavior |
|---|---|---|
| Normal | widget_display_mode !== 'hidden_zone_click' |
Widget visible immediately |
| Hidden Zone | widget_display_mode === 'hidden_zone_click' |
16x16px invisible button, widget appears on click |
| Copilot | global_client_config.features.copilot_mode + form page |
Right-side panel, starts minimized at bottom-right |
Hidden Zone Mode - Business Purpose¶
Hidden zone mode is activated on a per-client basis by setting widget_display_mode: 'hidden_zone_click' in the site's Supabase configuration.
Why it exists: This mode enables launching the widget in production on customer websites without showing it explicitly to their visitors. It's used for:
- Client testing in production - Clients can click on the hidden zone (bottom-left corner) to open the widget and test it in their real production environment
- Soft launches - Deploy to production without committing to full visibility
- Internal QA - Team members can test on the live site without affecting user experience
Visitor impact: While some visitors might randomly click on the hidden zone and discover Rose, this is very unlikely given the small (16x16px) invisible target area.
Copilot Mode - Form Assistance¶
Copilot mode is an alternative widget form factor designed to assist users on CTA/form pages. Instead of showing the standard search bar, copilot mode provides a streamlined chat experience for form completion assistance.
Key visual differences:
| Aspect | Default Mode | Copilot Mode |
|---|---|---|
| Minimized position | Bottom-center | Bottom-right (32px from edges) |
| Button text | "Ask" | "Need help?" |
| Expanded layout | Centered popup | Right-side panel |
| Initial state | Collapsed (search bar) | Minimized (button only) |
| Placeholder text | "Ask anything about {company}" | "How can we help you?" |
Welcome message: On first expansion in copilot mode, the widget displays a synthetic "assistant" message with up to 3 dynamic follow-up questions related to the current page (e.g., "Where to find my SIREN number?" on a form asking for it).
Mobile restriction: Copilot mode is never enabled on mobile devices. On mobile, the widget always uses the default form factor regardless of the feature flag setting.
When activated: See Widget Display Logic for the complete decision flowchart.
Component-Level View States¶
Managed by useWidgetReducer hook in the shared package:
┌─────────────────────────────────────────────────────────────┐
│ Default Mode Widget States │
├─────────────────────────────────────────────────────────────┤
│ │
│ ┌─────────────┐ user types ┌─────────────┐ │
│ │ COLLAPSED │ ───────────────► │ EXPANDED │ │
│ │ (search bar)│ │ (full chat) │ │
│ └─────────────┘ └─────────────┘ │
│ ▲ │ │
│ │ │ user clicks │
│ │ user clicks │ minimize │
│ │ minimized button ▼ │
│ │ ┌─────────────┐ │
│ └──────────────────────── │ MINIMIZED │ │
│ │ (icon only) │ │
│ └─────────────┘ │
└─────────────────────────────────────────────────────────────┘
Copilot Mode Flow: In copilot mode, the widget starts minimized and skips the collapsed view entirely:
┌─────────────────────────────────────────────────────────────┐
│ Copilot Mode Widget States │
├─────────────────────────────────────────────────────────────┤
│ │
│ ┌─────────────┐ user clicks ┌─────────────┐ │
│ │ MINIMIZED │ ────────────────► │ EXPANDED │ │
│ │ (button) │ │ (side panel)│ │
│ └─────────────┘ ◄──────────────── └─────────────┘ │
│ blur/minimize │
│ │
│ First expansion creates welcome message with follow-ups │
└─────────────────────────────────────────────────────────────┘
State Interface¶
interface WidgetState {
isExpanded: boolean; // Full chat view visible
isExplicitlyMinimized: boolean; // User clicked minimize
userClickedHiddenZone: boolean; // Hidden zone activated
isLoading: boolean;
hasTrackedImpression: boolean; // One-time analytics flag
hasTrackedHover: boolean;
error: string | null;
}
View Components¶
| Component | Location | When Shown |
|---|---|---|
| CollapsedChatView | shared/src/components/CollapsedChatView.tsx |
!isExpanded && !isExplicitlyMinimized (default mode only) |
| ExpandedChatView | shared/src/components/ExpandedChatView.tsx |
isExpanded && conversationPairs.length > 0 |
| MinimizedChatView | shared/src/components/MinimizedChatView.tsx |
!isExpanded && isExplicitlyMinimized |
| HiddenZoneButton | shared/src/components/HiddenZoneButton.tsx |
displayMode === 'hidden_zone' && !userClickedHiddenZone |
Copilot mode adaptations:
- MinimizedChatView: Positioned at bottom-right (32px from edges) instead of bottom-center; uses "Need help?" button text
- ExpandedChatView: Uses right-side panel layout instead of centered popup
- CollapsedChatView: Not used in copilot mode (widget starts minimized)
State Transitions¶
| Action | From State | To State | Trigger |
|---|---|---|---|
EXPAND |
Collapsed/Minimized | Expanded | User sends message |
COLLAPSE |
Expanded | Collapsed | All messages cleared |
MINIMIZE |
Expanded | Minimized | User clicks minimize button |
HIDDEN_ZONE_CLICKED |
Hidden | Expanded | User clicks hidden zone button |
Display Decision Flow (DisplayController)¶
For a comprehensive flowchart and detailed explanation of when the widget shows or hides, see Widget Display Logic.
1. Check for initialization errors → FAIL CLOSED (don't render)
2. Wait for PostHog analytics ready
3. Check traffic_allocated (deterministic bucketing)
4. Check display rules (URL patterns, device type)
5. Determine widget_display_mode (normal vs hidden_zone)
6. Render RoseWidget with displayMode prop
Supabase Configuration¶
Tables Used¶
1. site_configs (Primary Configuration)¶
Identity and Theme:
| Field | Type | Controls |
|---|---|---|
domain |
string | Primary key, matches website domain (normalized to root) |
name |
string | Human-readable site name for analytics |
color |
hex | Widget theme/button color |
default_language |
string | Fallback language for the site |
API and Service Routing:
| Field | Type | Controls |
|---|---|---|
api_endpoint |
URL | Site-specific API override |
legacy_api_endpoint |
URL | Switches to legacy N8N mode if set |
conversation_endpoint |
URL | N8N conversation webhook for analytics |
evaluation_endpoint |
URL | Evaluation webhook; fallback is analytics-based |
model |
string | LLM model override passed to backend |
Rollout and Display Control:
See Widget Display Logic for the complete decision flowchart.
| Field | Type | Controls |
|---|---|---|
traffic_allocated |
0-100 | Gradual rollout percentage (deterministic bucketing) |
widget_display_mode |
string | 'hidden_zone_click' or normal |
display_only_on_urls |
string[] | URL inclusion patterns (whitelist) |
CTA and Conversion:
| Field | Type | Controls |
|---|---|---|
ctas |
JSON[] | CTA definitions with placeholder, cta_id, langs.{url,text,bookingMessage}, type |
session_utm_name |
string | UTM parameter name added to CTA URLs |
dynamic_questions |
object/array | Question rotation config (supports per-page rules) |
custom_config Fields:
| Field | Type | Purpose |
|---|---|---|
enable_mobile |
boolean | Show widget on mobile (default: false) |
z_index |
number | Widget stacking order (default: 9999) |
exclude_url_patterns |
string[] | URL exclusion patterns |
hide_search_bar_on_cta_pages |
boolean | Hide widget on CTA destination pages |
conversation_question_display_mode |
string | 'perplexity' or 'chat-gpt' style |
disable_followup_suggestions |
boolean | Disable AI follow-up suggestions |
booking_workflow |
boolean | Enable email collection before redirect |
additional_cta_destinations |
string[] | Track-only CTA destinations |
additional_form_tracking_pages |
string[] | Additional pages for form tracking |
thank_you_pages |
string[] | URL patterns for form submission detection |
always_show_disclaimer |
boolean | Show disclaimer in collapsed view |
custom_disclaimers |
object | Localized disclaimer text overrides |
dynamic_questions.number_of_questions |
number | How many questions to display (default: 2) |
analytics_providers.parent_window_events |
object | Cross-domain iframe postMessage config |
2. global_client_config (Global Flags)¶
Single-row table (id=1) with global feature flags:
- features.followup_suggestions - Global default for follow-ups
- features.copilot_mode - Enable copilot form factor on CTA/form pages (see Copilot Mode)
- agent_config.agents - Global agent definitions
Configuration Loading Flow¶
Widget Initialization
│
▼
SiteConfigProvider (context)
├── Initialize Supabase client (10s timeout)
├── Load global_client_config
├── Load site_configs by normalized domain
├── Apply runtime config overrides
├── Cache config (5-minute TTL)
└── Create InboundXService
│
▼
DisplayController
├── Wait for PostHog ready
├── Check traffic_allocated (deterministic bucketing)
├── Check display rules
└── Render RoseWidget
Accessing Configuration¶
// In React components
const { currentSiteConfig, globalClientConfig } = useSiteConfig();
const color = currentSiteConfig?.color;
const displayMode = currentSiteConfig?.widget_display_mode;
// In non-React code
import { getSiteConfig, getSiteColor } from '@shared/config/SiteConfigManager';
const config = getSiteConfig(domain);
Domain Normalization¶
Domains are normalized to root domains:
This ensures consistent matching with Supabase site_configs.domain.
Mobile Handling¶
Detection Methods¶
The widget uses two complementary approaches:
1. User Agent Detection¶
// shared/src/utils/device/mobileDetection.ts
export const isMobileUserAgent = (userAgent?: string): boolean => {
const ua = userAgent || navigator.userAgent;
return /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(ua);
};
export const getDeviceType = (userAgent?: string): 'mobile' | 'tablet' | 'desktop' => {
// Checks for iPad, Android tablets, then mobile devices
};
2. Screen Width Detection¶
// shared/src/config/constants.ts
export const MOBILE_DETECTION = {
SCREEN_WIDTH_BREAKPOINT: 1236, // px
};
Mobile Display Rules¶
Default behavior: Widget is HIDDEN on mobile unless explicitly enabled.
// shared/src/utils/domain/siteRestrictions.ts
export function isCurrentDeviceAllowed(siteConfig: SiteConfig | null): boolean {
const isMobile = isMobileDevice();
if (isMobile) {
return siteConfig?.custom_config?.enable_mobile === true;
}
return true; // Always allow desktop
}
Dynamic Questions on Mobile¶
Dynamic questions are disabled on mobile by code, even when enable_mobile is true:
// shared/src/utils/content/dynamicQuestionManager.ts
function getNumberOfDynamicQuestions(domain: string): number {
// Disable dynamic questions on mobile devices
if (isMobileDevice()) {
logger.debug('📱 Mobile device detected, disabling dynamic questions');
return 0;
}
// ... rest of function
}
This means on mobile, only the search bar is shown (no rotating question suggestions).
UI Adaptations¶
| Component | Desktop (≥1236px) | Mobile (<1236px) |
|---|---|---|
| CollapsedChatView | 800px fixed width | calc(100vw - 40px) responsive |
| ExpandedChatView | 67vh height (536-600px) | Full viewport with keyboard handling |
iOS Keyboard Handling¶
The widget has special handling for iOS virtual keyboard:
// shared/src/hooks/ui/useMobileKeyboardAdjustment.ts
export function useMobileKeyboardAdjustment(inputRef) {
// Uses VisualViewport API to detect keyboard height
// Dynamically adjusts container padding
// Locks body scroll when input focused
// Uses 100dvh (dynamic viewport height) as fallback
}
Shadow DOM Implementation¶
Why Shadow DOM?¶
- CSS Isolation: Widget styles don't affect host page, host styles don't affect widget
- Encapsulation: Widget is self-contained, predictable across any website
- CSP Compliance: Inline styles injected programmatically
Creation Process¶
// main.tsx
private createShadowContainer(containerId: string): { shadowHost, shadowRoot, widgetContainer } {
// 1. Create shadow host
shadowHost = document.createElement('div');
shadowHost.id = containerId;
// 2. Attach shadow root (open mode for debugging)
shadowRoot = shadowHost.attachShadow({ mode: 'open' });
// 3. Append to body FIRST (browser needs this for resource loading)
document.body.appendChild(shadowHost);
// 4. Inject CSS synchronously
this.injectCSS(shadowRoot);
// 5. Create container for React
widgetContainer = document.createElement('div');
widgetContainer.className = 'inboundx-widget-root rose-widget-root';
shadowRoot.appendChild(widgetContainer);
// 6. Create hidden zone container OUTSIDE shadow DOM
// This ensures fixed positioning works correctly for the hidden zone trigger
hiddenZoneContainer = document.createElement('div');
hiddenZoneContainer.id = `${containerId}-hidden-zone`;
hiddenZoneContainer.className = 'rose-hidden-zone-root';
document.body.appendChild(hiddenZoneContainer);
}
Important: The hidden zone button (16x16px invisible trigger) is rendered outside the shadow DOM in its own container. This is necessary because fixed positioning inside Shadow DOM can behave inconsistently across browsers.
CSS Injection¶
private injectCSS(shadowRoot: ShadowRoot): void {
// Check for existing style (idempotent)
const existingStyle = shadowRoot.querySelector('style[data-rose-widget-css]');
if (existingStyle) return;
// Create style element with marker
const style = document.createElement('style');
style.setAttribute('data-rose-widget-css', 'true');
style.textContent = widgetCSS; // Imported via ?inline
shadowRoot.appendChild(style);
}
CSS Variable Protection¶
Shadow DOM doesn't protect against CSS variable inheritance. The widget overrides critical variables:
/* shared/src/styles/shadow-dom-protection.css */
.rose-widget-root {
/* Override Tailwind spacing for pixel-based values */
--spacing: 4px;
/* Reset color variables */
--background: white;
--border: #e5e7eb;
}
Why this is necessary:
- Host page might have html { font-size: 20px; } affecting rem calculations
- Tailwind 4 uses CSS variables (var(--text-sm)) that can inherit incorrectly
- Explicit pixel values ensure consistent rendering
Font Loading¶
Fonts are loaded via @font-face in the injected CSS:
@font-face {
font-family: 'Satoshi Variable';
src: url('./fonts/Satoshi-Variable.ttf') format('truetype');
font-weight: 300 900;
font-display: swap; /* Render text immediately, swap when loaded */
}
The fonts are served from the same CDN path as the widget bundle.
Loader System¶
Purpose¶
The Rose Loader (rose-loader.js) is a universal widget loader that:
- Works in any environment (GTM, plain HTML, SPA)
- Loads React dependencies if not present
- Dynamically loads widget bundle (CSS is inlined in the JS)
- Handles initialization with retry logic
- Fires GTM dataLayer events
Source Files¶
| File | Description |
|---|---|
src/loader.ts |
TypeScript source code |
dist/rose-loader.js |
Compiled UMD bundle (~5KB) |
vite.loader.config.ts |
Build configuration |
The loader is built separately from the widget using its own Vite config.
How The Two Scripts Interact¶
Customer websites embed the widget using two script tags:
<!-- Script 1: Downloads and executes the loader -->
<script src="https://cdn.userose.ai/loader/rose-loader.js"></script>
<!-- Script 2: Uses the loader to initialize the widget -->
<script>
window.InboundXLoader.init({
api_key: "YOUR_API_KEY",
api_host: "https://api.userose.ai/rose"
});
</script>
How this works:
- Script 1 downloads
rose-loader.jsfrom the CDN - When the script executes, it creates
window.InboundXLoaderwith the public API - Script 2 runs immediately after (scripts execute in order)
- By this point,
window.InboundXLoaderexists, so.init()can be called - The loader then downloads and initializes
inboundx-widget.js
This is the same pattern used by jQuery, Google Analytics, and other embeddable scripts.
Configuration Options¶
| Option | Required | Default | Description |
|---|---|---|---|
api_key |
Yes | - | API authentication key |
api_host |
Yes | - | Backend API URL |
cdn_url |
No | GCS production bucket | Widget assets location (defaults to storage.googleapis.com/inboundx-cdn/widget/production) |
site_name |
No | document.title | Site name for analytics |
domain |
No | window.location.hostname | Domain override |
debug |
No | false | Enable loader console logging (note: does not enable widget debug logs) |
defer_init |
No | false | Don't auto-initialize |
forceInit |
No | false | Force re-initialization |
forceDomain |
No | hostname | Override domain for config lookup |
forceDisplay |
No | false | Bypass traffic control and hidden zone |
enableEvaluation |
No | false | Enable evaluation buttons if endpoint configured |
Enabling Widget Debug Logs
The loader's debug option only enables loader-level console logs. To enable full widget debug logging, use the widget's debug utilities after initialization:
Complete Loading Timeline¶
Here's what happens when a customer's page loads with the widget:
┌─────────────────────────────────────────────────────────────────┐
│ PHASE 1: LOADER (T+0ms to T+50ms) │
├─────────────────────────────────────────────────────────────────┤
│ Browser downloads rose-loader.js (~5KB) │
│ ├── IIFE executes immediately │
│ ├── Check: inside iframe? → skip all initialization │
│ ├── Check: already loaded? → skip │
│ ├── Create window.InboundXLoader object │
│ └── Fire GTM: inboundx_loader_ready │
└─────────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────┐
│ PHASE 2: INIT CALL (T+51ms) │
├─────────────────────────────────────────────────────────────────┤
│ Customer script calls InboundXLoader.init(config) │
│ ├── Validate api_key, api_host (log error and return if missing)│
│ ├── Auto-capture window.reveal (visitor enrichment) │
│ └── Check if React/ReactDOM available │
│ ├── YES → skip to Phase 4 │
│ └── NO → go to Phase 3 │
└─────────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────┐
│ PHASE 3: REACT LOADING (T+52ms to T+200ms) - if needed │
├─────────────────────────────────────────────────────────────────┤
│ Load from unpkg CDN (parallel, with SRI hashes): │
│ ├── react@18.3.1/umd/react.production.min.js │
│ └── react-dom@18.3.1/umd/react-dom.production.min.js │
│ Timeout: 15 seconds │
└─────────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────┐
│ PHASE 4: WIDGET LOADING (T+200ms to T+500ms) │
├─────────────────────────────────────────────────────────────────┤
│ Create <script> for inboundx-widget.js │
│ ├── Set data-api-key, data-debug-mode, data-force-domain, etc. │
│ ├── Append to document │
│ └── Fire GTM: inboundx_widget_loaded │
│ │
│ Widget script executes: │
│ ├── Check: inside iframe? → set stub API, exit │
│ ├── Create InboundXWidgetManager singleton │
│ └── Attach to window.InboundXWidget │
└─────────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────┐
│ PHASE 5: WIDGET INIT (T+501ms to T+700ms) │
├─────────────────────────────────────────────────────────────────┤
│ Loader polls for window.InboundXWidget (max 10s) │
│ └── Calls InboundXWidget.init(config) │
│ │
│ InboundXWidgetManager.init(): │
│ ├── Validate config (apiUrl, apiKey required) │
│ ├── Initialize Sentry (error tracking) │
│ ├── Check API health with retry logic │
│ ├── Create Shadow DOM container │
│ ├── Inject CSS into Shadow DOM │
│ └── Render React tree │
└─────────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────┐
│ PHASE 6: REACT INITIALIZATION (T+700ms to T+900ms) │
├─────────────────────────────────────────────────────────────────┤
│ SiteConfigProvider │
│ ├── Initialize Supabase client │
│ ├── Load global_client_config │
│ └── Load site_configs by domain │
│ │
│ AnalyticsProvider │
│ └── Initialize PostHog │
│ │
│ DisplayController │
│ ├── Wait for PostHog ready │
│ ├── Run canInitializeWidget() checks │
│ ├── Track rw_posthog_initialized │
│ └── Render RoseWidget (or null if blocked) │
└─────────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────┐
│ PHASE 7: READY (T+900ms to T+1000ms) │
├─────────────────────────────────────────────────────────────────┤
│ After 100ms delay (React mount buffer): │
│ ├── Set window.__InboundXWidgetReady = true │
│ ├── Fire queued onReady callbacks │
│ ├── Dispatch CustomEvent: inboundx_widget_ready │
│ ├── Dispatch CustomEvent: roseWidgetReady │
│ └── Fire GTM: inboundx_widget_ready │
│ │
│ Widget visible to user (if display allowed) │
│ └── Track rw_widget_impression │
└─────────────────────────────────────────────────────────────────┘
Iframe Blocking¶
The widget will not load inside iframes. Both the loader and widget check:
// Early check in both loader.ts and main.tsx
if (window.parent !== window) {
// Inside an iframe - skip initialization
// Prevents "unconfigured domain" errors in preview iframes, Typeform embeds, etc.
}
This is intentional: iframes have different domains, causing configuration lookup failures.
GTM Events¶
| Event | When | Properties |
|---|---|---|
inboundx_loader_ready |
Loader script executed | - |
inboundx_widget_loaded |
Widget script loaded | - |
inboundx_widget_ready |
Widget fully initialized | status: 'initialized' |
inboundx_widget_error |
Any error occurred | error_type, error_message |
Loader Public API¶
window.InboundXLoader = {
config: LoaderConfig | null, // Current configuration
apiKey: string | null, // API key
initialized: boolean, // Initialization status
init(config): void, // Main initialization
triggerInit(): void, // Manual trigger (after defer_init)
destroy(): void, // Cleanup
};
Widget Public API¶
Once the widget is loaded, it exposes a rich API on window.InboundXWidget:
Core Methods¶
window.InboundXWidget = {
// Initialization
init(config: WidgetConfig): Promise<WidgetInstance>,
destroy(containerId?: string): void,
getWidget(containerId?: string): WidgetInstance | undefined,
// Widget manager (advanced)
manager: InboundXWidgetManager,
// Version and config
version: string, // e.g., "1.99.0"
BREAKPOINT: number, // Mobile breakpoint (1236px)
};
Readiness API (for AI Sections)¶
The widget provides a readiness API for external scripts that need to trigger questions:
// Check if widget is ready
const ready = window.InboundXWidget.isReady();
// Register callback for when widget becomes ready
window.InboundXWidget.onReady(() => {
console.log('Widget is ready!');
});
// Trigger a question programmatically
window.InboundXWidget.triggerQuestion(
"How do I reset my password?",
"ai_section" // source for analytics
);
Window Flags¶
The widget sets these flags for external scripts:
| Flag | Set When | Purpose |
|---|---|---|
window.__InboundXWidgetLoaded |
Widget script executed | Script loaded check |
window.__InboundXWidgetReady |
Widget fully initialized | Ready state check |
Custom Events¶
The widget dispatches these events on window:
| Event | When | Use Case |
|---|---|---|
inboundx_widget_ready |
Widget ready | General integration |
roseWidgetReady |
Widget ready | GTM/analytics |
inboundx_trigger_question |
Question triggered | Internal use |
Debug Utilities¶
// Get widget status
window.InboundXWidget.getStatus();
// Returns: { scriptLoaded, activeWidgets, isReady, logLevel, posthogAvailable, ... }
// Enable debug logging
await window.InboundXWidget.enableDebug();
Code Patterns & Architecture¶
Provider Chain¶
<SentryErrorBoundary>
<SiteConfigProvider> {/* Loads Supabase config */}
<AnalyticsProvider> {/* Initializes PostHog */}
<DisplayController> {/* Makes display decision */}
<RoseWidget /> {/* Main chat component */}
</DisplayController>
</AnalyticsProvider>
</SiteConfigProvider>
</SentryErrorBoundary>
Adapter Pattern¶
For cross-platform support (widget, Chrome extension, preprod):
// Network Adapters
interface NetworkAdapter {
fetch(url: string, options?: RequestInit): Promise<Response>;
}
FetchNetworkAdapter // Standard fetch for web (shared package)
ChromeNetworkAdapter // chrome.runtime.sendMessage for extension
// Storage Adapters
interface StorageAdapter {
get(keys: string[]): Promise<Record<string, any>>;
set(items: Record<string, any>): Promise<void>;
remove(keys: string[]): Promise<void>;
}
WebStorageAdapter // localStorage for web (shared package)
ChromeStorageAdapter // chrome.storage.local for extension
ConfigurableStorageAdapter // Extends WebStorageAdapter with API config (preprod-ui only)
ConfigurableStorageAdapter (preprod-ui):
- Extends WebStorageAdapter
- Adds API key and endpoint configuration from app config
- Allows dynamic API URL switching for testing different environments
Streaming Pattern¶
SSE streaming with chunk handling:
// shared/src/hooks/chat/useStreamingMessage.ts
const { done, value } = await reader.read();
const chunk = decoder.decode(value, { stream: true });
// Parse newline-delimited JSON
const lines = buffer.split('\n');
for (const line of lines) {
const data = JSON.parse(line.replace('data: ', ''));
switch (data.type) {
case 'thinking': onThinking(data.content);
case 'token': onToken(data.content);
case 'complete': onComplete(data);
}
}
Error Handling¶
// Centralized logger with Sentry integration
import { logger } from '@shared/utils/logger';
logger.debug('Debug info'); // Stripped in production
logger.info('User action', data); // Stripped in production
logger.warn('Deprecation warning'); // Sent to Sentry
logger.error('API failed', error); // Sent to Sentry
// Error boundary for React errors
<SentryErrorBoundary>
<RoseWidget />
</SentryErrorBoundary>
State Management¶
Uses useReducer pattern with action creators:
// shared/src/hooks/widget/useWidgetReducer.ts
const { state, actions, getState } = useWidgetReducer();
// Clean action interface
actions.expand();
actions.collapse();
actions.minimize();
actions.hiddenZoneClicked();
Analytics Integration¶
PostHog Events¶
All events use rw_ prefix (Rose Widget):
| Event | Trigger | Key Properties |
|---|---|---|
rw_widget_impression |
Widget becomes visible | siteName, displayMode |
rw_message_sent |
User sends message | messageNumber, message_source |
rw_cta_clicked |
CTA button clicked | cta_url, cta_text |
rw_posthog_initialized |
Analytics ready | displayAllowed, trafficControlPassed |
rw_click_hidden_zone |
Hidden zone clicked | clickMethod, hiddenZoneType |
Event Context¶
setAnalyticsContext({
sessionId,
clientId,
domain,
deploymentTarget, // 'widget' | 'chrome-plugin' | 'preprod-ui'
apiVersion,
environment,
conversation_triggered_by,
});
Message Source Tracking¶
user_typed- Manual inputdynamic_question- Clicked suggestionchat_history- Restored from storage
Development Workflow¶
Local Development¶
# Widget with Shadow DOM testing
cd frontend/widget
just dev
# Opens http://localhost:8083/demo.html
# Loads ../.env.test for configuration
# Generates demo pages via scripts/build-demo.cjs
# Preprod UI for fast iteration (no Shadow DOM)
cd frontend
just dev
# Opens http://localhost:3001
Note on forceDisplay:
- Local demos (just dev): forceDisplay is false - traffic control and hidden zone can be tested
- CDN demo pages (just deploy-demo): forceDisplay is true - widget always shows
Testing Flow¶
- Preprod UI (port 3001) - Fast CSS/UI iteration with hot reload
- Widget Demo (port 8083) - Production-like Shadow DOM testing
- Chrome Plugin - Test on real client websites locally
- Client Production - Final validation on specific sites
Key Differences¶
| Aspect | Preprod UI | Widget Demo |
|---|---|---|
| Shadow DOM | No | Yes |
| CSS Isolation | No | Yes |
| Hot Reload | Yes | No (rebuild needed) |
| Bundle | Direct imports | UMD bundle |
CDN Deployment¶
# Deploy to specific domain
just deploy abtasty.com # Production
just deploy abtasty.com staging # Staging
# Deploy loader
just deploy-loader
# Rollback
just undeploy abtasty.com
Cache Strategy¶
| Asset | Cache Duration |
|---|---|
Main loader (rose-loader.js) |
1 hour (+ stale-while-revalidate 24h) |
Widget bundle (inboundx-widget.js) |
1 minute |
| Versioned assets | 1 year |
CDN Infrastructure¶
Architecture¶
Customer Browser
│
▼
cdn.userose.ai (Cloudflare Worker)
│
▼
Google Cloud Storage (gs://inboundx-cdn)
Storage Structure¶
gs://inboundx-cdn/
├── loader/
│ ├── rose-loader.js # Current version
│ └── versions/{DATE}/
│ └── inboundx-loader.js # Versioned backup
│
└── widget/
├── {domain}/ # Per-customer bundles
│ ├── inboundx-widget.js
│ └── fonts/
│ └── Satoshi-Variable.ttf
│
├── {domain}_{env}/ # Environment-specific
│ └── inboundx-widget.js # e.g., abtasty.com_staging
│
└── production/ # Default fallback
└── inboundx-widget.js
Cloudflare Worker¶
The CDN is fronted by a Cloudflare Worker (backend/cloudflare/cloudflare-cdn-proxy.js) that:
- Proxies requests from
cdn.userose.aito GCS - Handles CORS preflight (OPTIONS) requests
- Sets cache headers based on file type
- Forces correct
Content-Typefor JS/CSS files - Adds cache status headers (
X-Cache-Status,X-Cache-TTL)
Cache Purging¶
After deployments, caches are purged via:
This uses scripts/release/cloudflare_purge.py which:
1. Reads Cloudflare credentials from GCP Secret Manager
2. Purges both CDN and legacy GCS URLs
3. Optionally verifies the new version is being served
Storage and Persistence¶
LocalStorage Structure¶
The widget uses several localStorage keys:
| Key Pattern | Purpose |
|---|---|
rose |
Analytics counters, traffic control cache, preprod settings |
inboundx_chat_state_${siteName} |
Per-domain conversation state (messages, expand/minimize) |
Session Storage¶
| Key Pattern | Purpose |
|---|---|
inboundx_session_${apiUrl} |
Session ID per API endpoint |
Traffic Control Cache¶
Traffic control decisions are cached in rose.widget.trafficControl:
{
clientId: string; // PostHog distinct_id or local UUID
trafficAllocated: number;
enabled: boolean;
bucket: number; // 0-99 deterministic bucket
timestamp: number;
}
Chat State Persistence¶
ChatStorageService manages conversation persistence:
- Stores conversation pairs (question + response)
- Tracks expand/minimize state
- Uses isNewNavigation() to determine restore vs reset behavior
Visitor Enrichment¶
Browser Reveal Integration¶
The loader auto-captures window.reveal data if present on the page and passes it to the widget for visitor identification.
Snitcher Radar SDK¶
The widget loads the Snitcher Radar SDK to capture session UUIDs for backend enrichment:
// shared/src/hooks/useSnitcherRadar.ts
export function useSnitcherRadar(): void {
// Loads SDK from cdn.snitcher.com
// Initializes with namespace "Rose"
// Captures sessionManager.currentSession.id
// All tracking features disabled (only need session UUID)
}
The session ID is retrieved via getSnitcherSessionId() and sent with API requests.
API Endpoints¶
| Endpoint | Method | Purpose |
|---|---|---|
/api/version |
GET | Health check + environment detection |
/api/lightrag/query |
POST | Non-streaming chat query |
/api/lightrag/query/stream |
POST | Streaming chat (SSE) |
/api/lightrag/reset_memory |
POST | Clear conversation history |
/api/lightrag/sites |
GET | List supported sites |
Key Files Quick Reference¶
| File | Purpose |
|---|---|
widget/src/main.tsx |
Entry point, Shadow DOM, public API |
widget/src/loader.ts |
Universal loader |
widget/src/components/DisplayController.tsx |
Display decision logic, formFactor determination |
shared/src/components/RoseWidget.tsx |
Main chat component |
shared/src/context/SiteConfigContext.tsx |
Supabase config provider |
shared/src/hooks/widget/useWidgetReducer.ts |
State management |
shared/src/utils/device/mobileDetection.ts |
Mobile detection |
shared/src/services/InboundXService.ts |
API communication |
shared/src/config/constants.ts |
FormFactor type, copilot text constants |
shared/src/config/SiteConfigManager.ts |
shouldUseCopilotMode(), isCopilotModeEnabled() |
shared/src/services/chatStorage.ts |
createCopilotWelcomePair() factory |
Troubleshooting¶
Widget Not Loading¶
Enable debug mode to see detailed logs:
Check GTM events in browser console:
Verify API health:
- Check Network tab for
/api/versionrequest - Should return 200 with environment info
Widget Not Visible¶
| Symptom | Likely Cause | Solution |
|---|---|---|
| Widget never appears | traffic_allocated = 0 |
Check Supabase site_configs |
| Widget missing on some pages | URL exclusion pattern | Check exclude_url_patterns |
| Widget missing on mobile | Mobile disabled (default) | Set enable_mobile: true |
| Widget appears inconsistently | Traffic bucketing | Check traffic_allocated < 100 |
Force display for testing:
window.InboundXLoader.init({
// ...
forceDisplay: true // Bypasses traffic control + hidden zone
});
Common Error Messages¶
| Error | Cause | Solution |
|---|---|---|
| "Widget cannot be initialized inside an iframe" | Widget in iframe | Expected - widget skips iframes |
| "apiUrl is required" | Missing config | Check loader init params |
| "apiKey is required" | Missing API key | Add api_key to config |
| "Backend connection failed" | API unreachable | Check API URL, network |
Debug Utilities¶
// Check widget status
window.InboundXWidget.getStatus()
// Enable debug logging after init
await window.InboundXWidget.enableDebug()
// Check loader state
console.log(window.InboundXLoader.config)
console.log(window.InboundXLoader.initialized)
// Check window flags
console.log(window.__InboundXWidgetLoaded) // Script loaded
console.log(window.__InboundXWidgetReady) // Widget ready
Supabase Configuration Check¶
Query to debug display issues: