Widget Integration

The UserTold.ai widget embeds directly in your product. Participants never leave your site — they see a floating button, answer the intake, and start an interview, all in-context.

Basic Embed

Add one line to your page:

<script
  async
  src="https://usertold.ai/v1/widget.js"
  data-project-key="ut_pub_YOUR_KEY"
  data-screener-id="your-screener-handle"
></script>

That's it. A floating button appears in the bottom-right corner shortly after your page finishes loading.

widget.js is a tiny (~0.6KB) self-deferring loader: it waits for your page's load event, then fetches the real widget bundle (widget-core.js) at background priority — the widget never competes with your page's own content for bandwidth or main-thread time. There is nothing extra to configure; performance-safe loading is the default. See Performance for the details.

The public widget assets are served from https://usertold.ai. Keep that separate from the dashboard origin (https://app.usertold.ai) and any API endpoints your integration may call.

Performance

The widget is designed to stay off your page's critical render path, with no integrator-side work:

  • Two-stage loading — the widget.js you embed is a ~0.6KB loader. The real bundle (widget-core.js) is fetched only after window.load, scheduled at background priority (scheduler.postTask where available, requestIdleCallback with a 3s timeout elsewhere, setTimeout as the floor for Safari). Your Largest Contentful Paint (LCP) never waits on UserTold.
  • Connection warm-up — the loader adds a preconnect hint for usertold.ai when scheduling begins, so the deferred bundle and API fetches skip DNS/TLS setup (typically 100–500ms) without holding a socket open during your page's startup.
  • Data saver — when a visitor has data-saver enabled (navigator.connection.saveData), the loader skips loading the widget entirely out of respect for their choice.
  • Data fetching — when the widget boots with a screenerId or studyId, it fetches that configuration from the UserTold API before showing the launcher. This request also happens after your page is fully loaded.
  • Failure isolation — if the widget bundle or API is slow or unreachable, the launcher simply doesn't appear. Your page is never blocked, and no broken UI is shown while the widget is pending.
  • Calling the API before the bundle loads — the loader initializes window.UserTold as a command queue with callable method stubs. Both styles work immediately and replay in order on boot:
<script>
  // Either style, any time after the embed tag:
  UserTold.identify('user_123', { plan: 'pro' });
  window.UserTold.push(['identify', 'user_123', { plan: 'pro' }]);
</script>

Configuration

Via Script Tag

<script
  async
  src="https://usertold.ai/v1/widget.js"
  data-project-key="ut_pub_YOUR_KEY"
  data-screener-id="your-screener-handle"
  data-brand-color="#3b82f6"
  data-position="bottom-left"
></script>

Via JavaScript

Load the script without async so UserTold exists immediately (it's the ~0.6KB loader — the heavy bundle still loads deferred), or guard with window.UserTold = window.UserTold || [] and use the queue style:

<script src="https://usertold.ai/v1/widget.js"></script>
<script>
  UserTold.init({
    projectKey: 'ut_pub_YOUR_KEY',
    screenerId: 'your-screener-handle',
    brandColor: '#3b82f6',
    position: 'bottom-left',
    launcherText: 'Share your thoughts',
  });
</script>

Via Global Settings

<script>
  window.UserToldSettings = {
    projectKey: 'ut_pub_YOUR_KEY',
    screenerId: 'your-screener-handle',
    brandColor: '#3b82f6',
  };
</script>
<script src="https://usertold.ai/v1/widget.js"></script>

Configuration priority (highest to lowest): init() arguments > window.UserToldSettings > script tag data-* attributes > defaults.

Options

OptionTypeDefaultDescription
projectKeystringRequired. Public project key (ut_pub_...)
screenerIdstringIntake reference (ID or handle) for qualification flow
studyIdstringStudy reference (ID or handle) for direct interview (skips intake)
brandColorstringoklch(67% 0.135 47) (terracotta)Any valid CSS color — hex, named, or oklch(...). Hover / light / ink shades are derived via color-mix(in oklab, ...).
theme.light.bg / theme.light.fgstringOverride widget surface / text color in light mode. Leaving undefined keeps the default token.
theme.dark.bg / theme.dark.fgstringSame, for dark mode.
positionstringbottom-rightWidget position: bottom-right or bottom-left
defaultDocked'left' | 'right' | falsefalseCollapse the launcher into a side dock on initial render.
launcherTextstringShare feedbackText on the floating button
voiceAssistantbooleanfalseEnable OpenAI Realtime voice for talk segments. Requires the project's OpenAI key.
voicestringmarinVoice name for the AI interviewer.
voiceMode'auto''auto'Turn-detection mode (server VAD). push-to-talk is reserved but not yet implemented.
onCompletefunctionCallback fired when the widget is fully closed/destroyed.

Either screenerId or studyId is required. Use screenerId when you want to qualify participants first via an intake. Use studyId to go straight to the interview.

Participant Flow

  1. Launcher — Floating button with your brand color and text. Non-intrusive.
  2. Intake — Multi-step qualification form. One question per screen with progress dots. Includes consent checkbox on the last step.
  3. Permissions — Requests microphone access everywhere. On desktop browsers with screen capture support, it also requests screen sharing. On mobile or unsupported devices, interviews continue with audio and in-page events only.
  4. Interview — Evidence-first conversation. The study script moves through talk, scripted speak, and silent observe segments so participants can complete tasks and then debrief from captured context.
  5. Complete — Thank-you message with optional incentive information.

If a participant doesn't qualify, they see a polite disqualification message and can close the widget.

Programmatic Control

window.UserTold exposes the following methods. Before the core bundle boots, calls are queued (the loader installs method stubs that record ['method', ...args] tuples) and replay in order on boot (see Performance):

MethodWhat it does
init(config?)Initialize the widget. Merges config over window.UserToldSettings and script data-* attributes.
activate(studyId, options?)Switch the launcher to a different active study. Safe to call on SPA route changes — skips if an interview is in progress. options.defaultDocked lets you pin the launcher to 'left', 'right', or false.
deactivate()Tear down the current widget instance without resetting global config. Returns false if an active interview is preserved instead of being torn down.
startInterview(options?)Open the interview directly. options.screenerId or options.studyId overrides whatever was configured.
identify(userId, traits?)Attach a host-side user identifier and free-form traits to the current widget interview for later correlation.
on(event, handler) / off(event, handler)Subscribe / unsubscribe to widget lifecycle events.
widget(config)Legacy: create a fully-configured WidgetInstance with open(), close(), destroy(), hasActiveSession(), dockToSide(side), undockFromSide(). Pre-boot calls are queued and the widget renders on boot, but the return value is undefined until the core has loaded — call it from on('ready', ...) or after load if you need the instance.
initializedBoolean — true once the widget has finished bootstrapping.

Start an Interview Directly

UserTold.startInterview({
  screenerId: 'your-screener-handle'
});

Switch Studies on Route Change

// In an SPA route handler, after navigating to /checkout
UserTold.activate('checkout-q1', { defaultDocked: 'right' });

Direct participants to a specific intake or study by adding URL parameters:

https://your-site.com?ut_start=beta-users
https://your-site.com?ut_study=checkout-q1

The widget auto-opens on page load when these parameters are present.

Identify Participants

UserTold.identify('user_123', {
  email: 'user@example.com',
  name: 'Jane Doe',
  plan: 'pro',
  // Any custom traits
});

Styling

The widget renders inside a Shadow DOM to avoid CSS conflicts with your site. It uses fixed positioning with a high z-index to float above your content.

Customizable via configuration:

  • Brand color — applied to buttons, progress indicators, and accents
  • Position — bottom-right or bottom-left corner
  • Launcher text — button label

Technical Details

The widget ships in two stages:

  1. Loader (widget.js, ~0.6KB) — the embed entrypoint. Sets up the window.UserTold command queue and schedules the core bundle after window.load at background priority (see Performance)
  2. Core (widget-core.js) — reads configuration from your embed tag (or window.UserToldSettings / queued init), discovers the intake or study, and renders the launcher; the interview UI stays dormant until the participant clicks the launcher

This keeps the integration one line while keeping the initial page impact near zero.

Troubleshooting

IssueSolution
Widget doesn't appearCheck that projectKey is set and valid (ut_pub_...). Ensure screenerId or studyId is configured.
Microphone permission deniedThe participant must grant microphone access. Check browser permission settings.
Screen sharing is unavailable on mobileThe widget automatically falls back to microphone and in-page event capture on mobile and other devices without usable screen capture.
"No active study linked"The intake must have a linked study with status active.
Widget styles look brokenCheck for Content Security Policy (CSP) rules that might block inline styles or the widget script.
Interview hangsCheck browser console for WebSocket errors. Verify the study is active and API keys are configured.

See also

  • Quickstart — end-to-end setup including embed step
  • Studies — configure what the widget asks