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. SPA Navigation Handling
  9. Loader System
  10. Widget Public API
  11. Code Patterns & Architecture
  12. Analytics Integration
  13. Development Workflow
  14. CDN Infrastructure
  15. Storage and Persistence
  16. Visitor Enrichment
  17. API Endpoints
  18. Key Files Quick Reference
  19. 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
        │   ├── ConfigProvider (Supabase resolver 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 Unified form_assistant feature + 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 Layout and Form Assistant

The widget now treats copilot as a layout and form assistant as a separate behavior mode.

  • formFactor — runtime presentation input passed into RoseWidget. It can force copilot presentation in preprod/testing flows.
  • viewLayout — widget UI state for the expanded chat only ('expanded' or 'copilot'). This is the visitor's saved layout preference when the copilot layout toggle is enabled.
  • isFormAssistant — derived page/context mode. This is not widget UI state. When active, it forces copilot layout and enables form-specific behavior.

In regular widget mode, visitors can optionally switch the expanded chat between the centered layout and the copilot side panel. In form assistant mode, the side panel is forced and the extra form-assistance rules apply.

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?"

Desktop layout toggle: When appearance.enable_copilot_layout is enabled, desktop visitors can switch between the centered expanded view and the copilot side panel using the header toggle. The selected viewLayout is persisted in session storage.

Forced copilot behavior: Form assistant forces copilot layout regardless of the saved viewLayout. If the copilot toggle is disabled, the widget resolves back to the default expanded layout unless form assistant or a forced formFactor explicitly locks copilot.

Welcome message: On first expansion in form assistant 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: Form assistant is never enabled on mobile devices. On mobile, the widget always falls back to the default flow. The copilot layout toggle is also desktop-only.

When activated: See Widget Display Logic and Expanded Layout Resolution.

Component-Level View States

Managed by useWidgetReducer hook in the shared package:

┌─────────────────────────────────────────────────────────────┐
│                 Default Mode Widget States                   │
├─────────────────────────────────────────────────────────────┤
│                                                             │
│  ┌─────────────┐    user types    ┌────────────────────┐   │
│  │  COLLAPSED  │ ───────────────► │ EXPANDED (centered)│   │
│  │ (search bar)│                  └─────────┬──────────┘   │
│  └─────────────┘                            │               │
│        ▲                          toggle    │               │
│        │                     header button   ▼               │
│        │                              ┌────────────────────┐│
│        │ user clicks                  │ EXPANDED (copilot) ││
│        │ minimized button             │   side panel       ││
│        │                              └─────────┬──────────┘│
│        │                                        │            │
│        │       minimize from centered           │ minimize   │
│        │                 ▼                      ▼            │
│        │        ┌─────────────┐        ┌─────────────┐      │
│        └────────│ MIN-CENTER  │        │ MIN-RIGHT   │      │
│                 │ (icon only) │        │ (icon only) │      │
│                 └─────────────┘        └─────────────┘      │
│                                                             │
└─────────────────────────────────────────────────────────────┘

Top-level widget state is still driven by isExpanded and isExplicitlyMinimized. viewLayout only changes how the expanded view is rendered and where the minimized button docks after close.

Form Assistant Flow: In form assistant mode, the widget starts minimized and skips the collapsed view entirely:

┌─────────────────────────────────────────────────────────────┐
│                 Form Assistant Widget States                  │
├─────────────────────────────────────────────────────────────┤
│                                                             │
│  ┌─────────────┐    user clicks    ┌─────────────┐         │
│  │  MINIMIZED  │ ────────────────► │  EXPANDED   │         │
│  │ (button)    │                   │ (side panel)│         │
│  └─────────────┘ ◄──────────────── └─────────────┘         │
│                    blur/minimize                            │
│                                                             │
│  First expansion creates welcome message with follow-ups    │
└─────────────────────────────────────────────────────────────┘

**Layout Toggle:** In default mode, users can toggle between expanded (centered) and copilot (side panel) layouts using a button in the header. The toggle is NOT available in form assistant mode, which always uses copilot layout.

### State Model

The widget keeps layout and behavior separate:

```typescript
interface WidgetState {
  isExpanded: boolean;
  isExplicitlyMinimized: boolean;
  userClickedHiddenZone: boolean;
  isLoading: boolean;
  hasTrackedImpression: boolean;
  hasTrackedHover: boolean;
  error: string | null;
  viewLayout: 'expanded' | 'copilot';  // expanded-view layout only
}

interface RuntimeContext {
  formFactor: 'default' | 'copilot';   // incoming presentation input
  isFormAssistant: boolean;            // derived page/context behavior mode
}

isFormAssistant comes from DisplayController / SiteConfigManager, not from the widget reducer. This keeps user-driven layout preference separate from page-driven form-assistant behavior.

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 layout 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: Skipped only in the form assistant flow, where the 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
SWITCH_LAYOUT Expanded Expanded (layout toggled) User clicks layout toggle button
SET_VIEW_LAYOUT Expanded Expanded (layout normalized) Internal sync when copilot toggle is disabled

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. public.domains + config.client_configs (Website Configuration)

Website config is split across resolver slugs rather than a single row-shaped table:

Slug Controls Example fields
identity Company name and fallback language company_name, default_language
appearance Theme and presentation brand_color, z_index, disclaimers
traffic_control Rollout, URL rules, mobile handling, display mode traffic_percentage, display_only_on_urls, page_display_rules, enable_mobile, widget_display_mode
system API and model routing api_endpoint, evaluation_endpoint, conversation_endpoint, model
ctas CTA URLs and inline CTA behavior ctas, inline_cta
analytics Form tracking and iframe analytics forwarding form_tracking_pages, thank_you_pages, parent_window_events, session_utm_name
engagement Dynamic questions and follow-up behavior dynamic_questions, followup_suggestions
qualification Profiling forms and enrichment behavior profiling, enrich_all_visitors
form_assistant Page-level copilot form assistant rules pages

2. Unified Global Config

Global defaults come from config.configs, with per-domain overrides in config.client_configs:

  • engagement.followup_suggestions.enabled - Default for follow-ups
  • form_assistant feature - Enable copilot form factor on CTA/form pages (see Form Assistant)
  • agents.agents - Global agent definitions

Configuration Loading Flow

Widget Initialization
ConfigProvider (context)
    ├── Initialize Supabase client (10s timeout)
    ├── Load matching domain from public.domains
    ├── Load unified config definitions
    ├── Load domain overrides from config.client_configs
    ├── 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 { currentDomain, currentResolver } = useSiteConfig();
const { resolver } = useConfig();
const color = currentResolver?.appearance.brand_color;
const displayMode = currentResolver?.traffic_control.widget_display_mode;
const followupSuggestionsEnabled = resolver?.engagement?.followup_suggestions?.enabled;

// In non-React code
import { getSiteColor, getWidgetDisplayMode } from '@shared/config/SiteConfigManager';
const color = getSiteColor(domain);
const displayMode = getWidgetDisplayMode(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 public.domains.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.


SPA Navigation Handling

Single Page Applications (SPAs) often change URLs without a full page reload and can replace large parts of the DOM. The widget explicitly handles both behaviors:

URL-Change Re-evaluation

  • SPA handling is opt-in in unified config (traffic_control.spa_widget_handling), then exposed at runtime via custom_config.spa_widget_handling.
  • Runtime gate: shouldEnableSpaHandling(domain, currentUrl):
  • spa_widget_handling.enabled === true
  • and, if present, at least one spa_widget_handling.url_patterns match
  • Domain scoping follows resolver matching (exact host first, then normalized fallback), so behavior only applies to domains resolved by the active config.
  • URL change notifications are captured from pushState, replaceState, popstate, and hashchange.

Example (enable SPA handling only on a subdomain):

{
  "traffic_control": {
    "spa_widget_handling": {
      "enabled": true,
      "url_patterns": ["*start.pennylane.com*"]
    }
  }
}

DOM Re-attachment

  • Some SPAs replace the <body> or app root after navigation events fire.
  • The widget runs staggered recovery checks after URL change (0ms, 200ms, 600ms, 1200ms) to catch delayed teardown.
  • Recovery stays active for one transition when SPA handling was enabled on the previous URL, even if the new URL no longer matches patterns.
  • Re-init is triggered when widget DOM is not healthy, including the browser-history "zombie host" case (host still connected but shadow root/widget root missing).
  • Recovery uses the last stored widget config and forceInit: true.

Relevant files:

  • frontend/shared/src/utils/web/urlChange.ts (history/popstate/hashchange subscription)
  • frontend/shared/src/utils/web/spaHandling.ts (gate evaluation)
  • frontend/shared/src/config/SiteConfigManager.ts (shouldEnableSpaHandling)
  • frontend/widget/src/components/DisplayController.tsx (URL-change re-evaluation)
  • frontend/widget/src/main.tsx (DOM re-attachment + re-init)
  • frontend/chrome-plugin/entrypoints/content.tsx (extension re-render/recreate on navigation)
  • frontend/chrome-plugin/entrypoints/url-bridge.content.ts (MAIN-world URL bridge)

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)             │
├─────────────────────────────────────────────────────────────────┤
│  ConfigProvider                                                 │
│  ├── Initialize Supabase client                                 │
│  ├── Load unified config definitions                            │
│  └── Load domain row + resolver config                          │
│                                                                 │
│  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>
  <ConfigProvider>               {/* Loads Supabase resolver config */}
    <AnalyticsProvider>          {/* Initializes PostHog */}
      <DisplayController>        {/* Makes display decision */}
        <RoseWidget />           {/* Main chat component */}
      </DisplayController>
    </AnalyticsProvider>
  </ConfigProvider>
</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({ apiUrl }: { apiUrl?: string }): void {
    // Loads SDK via unified proxy (/account-context/sdk) when on api.userose.ai
    // Falls back to cdn.snitcher.com otherwise
    // 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. When running on api.userose.ai, both SDK and API traffic is routed through /account-context/sdk and /account-context/api respectively.


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 shouldUseFormAssistant(), isFormAssistantEnabled()
shared/src/services/chatStorage.ts createFormAssistantWelcomePair() 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_percentage = 0 Check the traffic_control override in Supabase
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 config.client_configs
WHERE domain = 'example.com'
  AND config_slug IN ('traffic_control', 'appearance', 'analytics')
ORDER BY config_slug;