Skip to content

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

  1. Overview
  2. Folder Structure
  3. Build System
  4. UI Modes & State Management
  5. Supabase Configuration
  6. Mobile Handling
  7. Shadow DOM Implementation
  8. Loader System
  9. Widget Public API
  10. Code Patterns & Architecture
  11. Analytics Integration
  12. Development Workflow
  13. CDN Infrastructure
  14. Storage and Persistence
  15. Visitor Enrichment
  16. API Endpoints
  17. Key Files Quick Reference
  18. 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

  1. Widget Build: src/main.tsxdist/inboundx-widget.js (UMD)
  2. Loader Build: src/loader.tsdist/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:

  1. 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
  2. Soft launches - Deploy to production without committing to full visibility
  3. 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:

www.example.com → example.com
api.example.co.uk → example.co.uk

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:

  1. Script 1 downloads rose-loader.js from the CDN
  2. When the script executes, it creates window.InboundXLoader with the public API
  3. Script 2 runs immediately after (scripts execute in order)
  4. By this point, window.InboundXLoader exists, so .init() can be called
  5. 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:

window.InboundXWidget.onReady(() => {
    window.InboundXWidget.enableDebug();
});

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 input
  • dynamic_question - Clicked suggestion
  • chat_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

  1. Preprod UI (port 3001) - Fast CSS/UI iteration with hot reload
  2. Widget Demo (port 8083) - Production-like Shadow DOM testing
  3. Chrome Plugin - Test on real client websites locally
  4. 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.ai to GCS
  • Handles CORS preflight (OPTIONS) requests
  • Sets cache headers based on file type
  • Forces correct Content-Type for JS/CSS files
  • Adds cache status headers (X-Cache-Status, X-Cache-TTL)

Cache Purging

After deployments, caches are purged via:

just purge-cdn-cache /widget/domain/inboundx-widget.js

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:

window.InboundXLoader.init({
    api_key: "...",
    api_host: "...",
    debug: true  // ← Enable logging
});

Check GTM events in browser console:

// Look for these in dataLayer
dataLayer.filter(e => e.event?.startsWith('inboundx_'))

Verify API health:

  • Check Network tab for /api/version request
  • 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:

SELECT
  domain,
  traffic_allocated,
  widget_display_mode,
  display_only_on_urls,
  custom_config->'exclude_url_patterns' as exclude_patterns,
  custom_config->'enable_mobile' as enable_mobile
FROM site_configs
WHERE domain = 'example.com';