@dolard.eu/versiq-widget
v0.6.0
Published
Versiq Widget SDK - Embed conversational qualification into your website.
Maintainers
Readme
@dolard.eu/versiq-widget
Versiq Widget SDK — embed a conversational conversion agent that answers visitors using your site's data (catalogue, inventory, CRM, knowledge base), not the public-web average a generic LLM assistant returns.
Table of Contents
- Installation
- Quick Start
- Why this widget converts
- Configuration
- API Reference
- Events
- TypeScript
- Vertical Resolution
- Display Modes
- React Integration
- Data Attributes (Script Tag)
- Security
- E2E Test Selectors (data-testid)
- Performance
- Browser Support
- Changelog
- Support
- License
Installation
npm install @dolard.eu/versiq-widget
# or
pnpm add @dolard.eu/versiq-widgetQuick Start
Script Tag (CDN)
Hot-link the latest published bundle from any public npm CDN. A single line is enough — the publishable key binds the widget to your Agent, everything else is resolved server-side.
<script
src="https://unpkg.com/@dolard.eu/versiq-widget@latest/dist/widget.umd.js"
data-api-key="pk_your_key"
></script>Equivalent via jsDelivr:
<script
src="https://cdn.jsdelivr.net/npm/@dolard.eu/versiq-widget@latest/dist/widget.umd.js"
data-api-key="pk_your_key"
></script>@latest always serves the most recent release, so a bug fix or improvement
reaches your visitors without you editing the snippet. If you need to pin a
specific version for change-control reasons, replace @latest with @<VERSION>
(see npm for the list)
— at the cost of having to bump it manually on every release.
Programmatic API
The minimal integration is one line — every visual and behavioural aspect (theme, position, language, open-state, branding) is configured in the Versiq admin portal for your Agent and resolved server-side from the API key.
import { createWidget } from "@dolard.eu/versiq-widget";
const widget = createWidget({ apiKey: "pk_live_your_key" });
// Control the widget at runtime
widget.open();
widget.close();
widget.reset();
// Cleanup (e.g., on SPA route change)
widget.destroy();Why this widget converts
Two reasons most platforms fail to convert mobile visitors, and two reasons this widget does:
- It speaks with your data, not the public-web average. The agent answers from your catalogue, your stock, your CRM, your knowledge base — what your competitors and generic AI assistants don't have.
- It is driven by your thumb, not your keyboard. The LLM dynamically picks the right component at every turn — quick replies, sliders, product cards — instead of forcing a form. Visitors qualify their need in a few taps on mobile, where 3+ field forms lose 50%+ of users (HubSpot).
The widget rendering layer (QuickReplies, PropertyCard, ActionButtons) is
the runtime that materialises this. Schemas live in the Versiq backend
repository and are out of scope for the SDK consumer.
Configuration
Widget configuration is split into three scopes — only the first one is your responsibility as an integrator.
1. Host-side (passed to createWidget or as data-* attributes)
These can only live on the integrator's page because they describe the host context (DOM, identity, environment).
| Option | Type | Default | Description |
| ----------- | ----------------------- | -------------- | ------------------------------------------------------------------------------------------------------- |
| apiKey | string | required | Publishable API key (pk_live_*, pk_test_*). Binds the widget to one Agent. |
| container | HTMLElement \| string | - | DOM container for inline mode. Required only when the admin has set position: "inline" on the portal. |
| baseUrl | string | Production URL | Override the widget iframe origin. Only useful for sandbox / self-hosted setups. |
| debug | boolean | false | Enable debug logging in the browser console. |
| email | string | - | Pre-identified visitor email — must be paired with userHash. |
| userId | string | - | Host-side stable user identifier — must be paired with userHash. |
| userHash | string | - | HMAC-SHA256 of email (or userId), signed with the Agent identity secret. See Identity. |
2. Server-resolved (configured in the Versiq admin portal)
These are not passed by the integration — they are stored in the Agent's
widget_config JSONB row and fetched at bootstrap from the API key. To change
any of them, edit the Agent in the portal.
| Field | Configured in admin | Notes |
| ----------------- | -------------------------- | ----------------------------------------------------------------------------------------------------------- |
| theme | Apparence → palette | Full palette (primaryColor, backgroundColor, textColor, borderRadius, fontFamily, colorScheme). |
| position | Apparence → position | "bottom-right", "bottom-left", or "inline". |
| language | Apparence → langue | ISO 639-1 (e.g. fr, en). Falls back to browser language when unset. |
| showProfile | Apparence → panneau profil | Toggles the profile panel in the widget header. |
| open | Apparence → état initial | Default open state on page load. Host can still call widget.open() programmatically. |
| brand.title | Branding → titre | Custom header title (falls back to a vertical-specific default). |
| brand.avatarUrl | Branding → avatar | Custom avatar URL (must originate from the portal upload — arbitrary URLs are rejected). |
| vertical | Agent creation | real-estate, b2b-qualification, … Bound to the API key. |
3. Client overrides (advanced — rarely needed in production)
For tooling, A/B previews or staging environments, every server-resolved field
above can also be passed as a WidgetConfig argument or data-* attribute. A
host-side value, when present, takes precedence over the admin value. This
is intentional — but in production you should configure things in the portal so
all your sites stay in sync.
// Override only for a staging preview — production should leave this out
createWidget({
apiKey: "pk_test_...",
theme: { primaryColor: "#3B82F6" },
position: "bottom-left",
});ThemeConfig shape (admin-defined, occasionally overridden)
| Option | Type | Description |
| ----------------- | ----------------------------- | ---------------------------------------------------------------------------------------------- |
| primaryColor | string | Primary brand color (hex, e.g., #3B82F6) |
| backgroundColor | string | Background color for the widget container |
| textColor | string | Text color |
| borderRadius | number | Border radius in pixels |
| fontFamily | string | Font family |
| colorScheme | "light" \| "dark" \| "auto" | Widget color scheme. "auto" follows the visitor's prefers-color-scheme. Defaults to light. |
Motion
The widget animates state changes (open/close resize, message and quick-reply
entrances, hover feedback) with a single, coherent motion language: a harmonic
duration scale and Material-style easing curves, all transitions kept under
400ms. These motion tokens are internal — they are not part of the
ThemeConfig contract and cannot be overridden by integrators (kept internal
until a concrete need arises). The widget fully honours
prefers-reduced-motion: reduce: when the visitor opts out, every transition
and animation is neutralised. Design rationale and the full token table live in
ADR-018.
API Reference
createWidget(config)
Creates a new widget instance. The minimal call is createWidget({ apiKey }) —
every behaviour comes from the admin portal. The example below illustrates the
inline mode where the integrator must additionally supply a container
(host-context only).
const widget = createWidget({
apiKey: "pk_live_your_key",
// Required only when the admin set position: "inline" on the portal
container: document.getElementById("widget-container"),
});VersiqWidget Methods
| Method | Description |
| ----------------------------------------------------- | -------------------------------------------------------- |
| open() | Open the widget |
| close() | Close the widget |
| reset() | Reset the conversation |
| setTheme(theme: ThemeConfig) | Update theme dynamically |
| setColorScheme(scheme: "light" \| "dark" \| "auto") | Sugar over setTheme({ colorScheme }) for dark-mode UIs |
| identify(params) | Set host-attested identity (HMAC) |
| destroy() | Remove widget and cleanup |
| on(event, handler) | Subscribe to events |
| off(event, handler) | Unsubscribe from events |
Window API (Script Tag)
When using the script tag, the API is available on window.Versiq:
window.Versiq.open();
window.Versiq.close();
window.Versiq.setTheme({ primaryColor: "#10B981" });
// Wire your own dark-mode toggle to the widget:
window.Versiq.setColorScheme("dark"); // or "light", or "auto"Events
Subscribe to widget events to react to user interactions.
widget.on("ready", () => {
console.log("Widget is ready");
});
widget.on("profile-update", () => {
console.log("Profile was updated"); // signal only, no data
});
widget.on("qualified", () => {
console.log("Lead qualified"); // signal only, no data
// Full profile + score: subscribe to the lead.qualified backend webhook
});
widget.on("message", (data) => {
console.log("New message:", data.message);
});
widget.on("error", (data) => {
console.error("Widget error:", data.code, data.message);
});Event Types
| Event | Payload | Description |
| --------------------- | ---------------------------------------------------- | ---------------------------------------------------------------------- |
| ready | - | Widget is loaded and ready |
| open | - | Widget was opened |
| close | - | Widget was closed |
| message | { message: WidgetMessage } | New chat message |
| profile-update | - | Profile was updated (signal only — subscribe to webhooks for the data) |
| qualified | - | Lead qualified (signal only — subscribe to the lead.qualified webhook for the data) |
| funnel-stage-change | { stage: string, previousStage: string \| null } | Conversion funnel stage transition |
| cta-shown | { ctaType: string, objectiveId?: string } | A call-to-action was displayed |
| cta-clicked | { ctaType: string, objectiveId?: string } | A call-to-action was clicked |
| lead-captured | { hasEmail: boolean, hasPhone: boolean } | Lead contact details captured |
| error | { code: string, message: string } | An error occurred |
| quota-warning | { remaining: number, limit: number } | Lead quota running low |
| quota-exceeded | - | Lead quota exceeded |
| identity-verified | { email: string, userId?: string } | Host identity verified (HMAC) |
| widget-unavailable | { reason: "timeout" \| "error", attempts: number } | SDK exhausted all load retries — the widget degrades to a static state |
TypeScript
The package includes full TypeScript support with exported types:
import type {
WidgetConfig,
ThemeConfig,
VersiqWidget,
WidgetEventType,
WidgetProfile,
WidgetMessage,
B2BProfile, // B2B-specific (backward compat)
} from "@dolard.eu/versiq-widget";Profile Types
WidgetProfile(Record<string, unknown>) — Generic profile used in events. Shape depends on the vertical (BuyerProfilefor real-estate,B2BProfilefor b2b-qualification).B2BProfile— Typed B2B qualification profile (sector, companySize, etc.). Kept for backward compatibility.
Vertical Resolution
The vertical (real-estate, b2b-qualification, …) is resolved server-side
from the API key — you do not pass it from the integration. Each pk_* key is
bound to exactly one Agent, and each Agent is bound to one vertical. Switching
vertical = creating a new Agent + new key.
Real-Estate flow
When the resolved vertical is real-estate, the widget runs in qualification
mode: Versiq qualifies the buyer through conversation and emits the profile — no
property data is needed from the integrator (data-dependent tools like
searchProperties, getCityStats, estimateProperty are automatically
excluded).
// Assumes the real-estate Agent is configured for `inline` mode in
// the admin portal. Only the host-context fields are passed here.
const widget = createWidget({
apiKey: "pk_live_your_real_estate_key",
container: "#chat",
});
// UX signals only — trigger a CTA or an analytics event, no lead data here
widget.on("profile-update", () => {
showProgressIndicator();
});
widget.on("qualified", () => {
showQualifiedBadge();
});The profile/score data itself never flows through postMessage (#1993) — it
is only delivered server-side via a POST to your configured webhookUrl on
lead.qualified, configured in the admin portal:
{
"event": "lead.qualified",
"data": {
"sessionId": "…",
"profile": { "userType": "prospect", "location": "…", "budget": 450000, "propertyType": "…" }
}
}Display Modes
Display mode (bottom-right, bottom-left, inline) is set in the admin
portal. From the integrator's side, the only difference is whether you also need
to supply a container.
Floating (admin chose bottom-right or bottom-left)
Widget appears as a floating button in the corner of the page. No container
needed.
createWidget({ apiKey: "pk_live_your_key" });Inline (admin chose inline)
Widget is mounted directly into a DOM container you provide.
createWidget({
apiKey: "pk_live_your_key",
container: document.getElementById("chat-container"),
});React Integration
"use client";
import { useEffect, useRef } from "react";
import { createWidget, type VersiqWidget } from "@dolard.eu/versiq-widget";
export function ContactWidget() {
const widgetRef = useRef<VersiqWidget | null>(null);
const containerRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!containerRef.current || widgetRef.current) return;
// Assumes the Agent is configured for `inline` mode in the admin
// portal — only the host-context fields (apiKey + container) are passed
// here. Theme, position, language, etc. come from the server config.
const widget = createWidget({
apiKey: "pk_live_your_key",
container: containerRef.current,
});
widgetRef.current = widget;
return () => {
widgetRef.current?.destroy();
widgetRef.current = null;
};
}, []);
return <div ref={containerRef} className="h-[600px]" />;
}Data Attributes (Script Tag)
All WidgetConfig options can be passed as kebab-cased data-* attributes on
the <script> tag. The same scope split as the JavaScript API applies — in
practice only data-api-key is needed.
Host-side (essential)
| Attribute | Maps to | Example |
| ---------------- | ----------- | --------------------------------------- |
| data-api-key | apiKey | data-api-key="pk_live_abc123" |
| data-container | container | data-container="#chat" |
| data-base-url | baseUrl | data-base-url="https://app.versiq.io" |
| data-debug | debug | data-debug="true" |
| data-email | email | data-email="[email protected]" |
| data-user-id | userId | data-user-id="usr_123" |
| data-user-hash | userHash | data-user-hash="<hmac>" |
data-container is a CSS selector and is only needed when the admin set
position: "inline" on the portal.
No theme / position / language overrides via
data-*. Unlike the programmatic API, server-resolved fields (theme,position,language,showProfile,open) cannot be overridden from the script tag — the legacydata-theme/data-position/data-language/data-show-profile/data-openattributes are silently ignored. Configure these in the admin portal, or usecreateWidget({ theme, position, … })for the tooling/preview override path.
Security
The widget runs inside an <iframe> in your visitors' browsers. Three host-side
concerns are worth understanding before going to production: identity
attestation, the host Permissions-Policy, and your Content-Security-Policy.
Identity verification (HMAC)
By default a visitor is anonymous. If your page already knows who the visitor is (a logged-in user), you can attest that identity to Versiq so leads are attached to the right account — without trusting a client-supplied email blindly.
Pass email (or userId) together with a userHash: an HMAC-SHA256 of the
email (or userId), computed server-side with your Agent's identity
secret (never expose that secret in the browser). The widget forwards the triple
and the backend recomputes the HMAC to verify it; on success it emits the
identity-verified event.
// Server-side (your backend) — pseudo-code
const userHash = hmacSHA256(user.email, AGENT_IDENTITY_SECRET);
// Client-side — pass the precomputed hash, never the secret
createWidget({
apiKey: "pk_live_your_key",
email: user.email,
userHash, // injected from your server-rendered page
});A mismatched or missing userHash leaves the visitor anonymous — the widget
keeps working, it just does not attest the identity.
Host page Permissions-Policy
Some widget features rely on browser-level permissions that must be granted by the host page — the iframe itself can't unlock a capability the parent document forbids.
| Feature | Browser permission | Required Permissions-Policy on the host |
| ------------------------------------------------------------------------------------------------------------------ | ---------------------------------------------------------- | ------------------------------------------------------------------------- |
| Autour de moi quick-reply (real-estate) — proposes nearby cities/districts based on the visitor's current coords | geolocation (navigator.geolocation.getCurrentPosition) | geolocation=(self) at minimum (or include the widget origin explicitly) |
| Copy property listing / share link buttons | clipboard-write | clipboard-write=(self) (most hosts already inherit the default) |
If your site already ships a Permissions-Policy header (recommended for
defense in depth — see
MDN),
add geolocation=(self) to the list. The widget iframe is created with
allow="clipboard-write; geolocation" so the parent's permission propagates
automatically once it's not denied.
Minimal header example:
Permissions-Policy: camera=(), microphone=(), geolocation=(self)What happens if you forget: the widget keeps working — except the
geolocation-backed quick-replies log
Geolocation has been disabled in this document by permissions policy. to the
visitor's console and the chip silently does nothing on click. The underlying
conversation still works; the visitor just types the city manually.
Content-Security-Policy
Permissions-Policy controls what the iframe is allowed to do;
Content-Security-Policy controls where scripts / iframes / styles can come
from. Both are independent and both are needed in production. The CSP
directives required for the Versiq widget are documented in the Versiq backend
repo at docs/B2B_INTEGRATION.md § "Content-Security-Policy pour intégrateurs".
E2E Test Selectors (data-testid)
For a smoke test that the widget mounted and works on your page, a small core of
data-testid selectors is exposed. These are guaranteed not to change without a
major version bump.
| Selector | Element | Additional attributes |
| ----------------------- | ----------------------------------------------------------------------------- | ---------------------------------- |
| widget-root | Chat container (open) | — |
| widget-input | Message input field | — |
| widget-send | Send button | — |
| widget-message | Message bubble | data-role="user" \| "assistant" |
| widget-loading-status | Running-tool status label beside the typing dots (e.g. "Recherche de biens…") | — (absent until a tool runs) |
| widget-suggestion | Quick reply chip | data-index="<N>" (position) |
| widget-cta-button | Call-to-action button | data-objective="<objectiveType>" |
| widget-avatar | Header avatar | — |
The widget also exposes a large set of vertical-specific selectors (real-estate map, areas-overview, exploration mode, criteria popup, range slider, contact form). They are internal to the Versiq UI and out of scope for SDK consumers — the full contract lives in the Versiq backend repository under
apps/app/src/app/widget/embed/components/.
Example (Playwright)
await page.getByTestId("widget-input").fill("Looking for a 2-bedroom in Lyon");
await page.getByTestId("widget-send").click();
await expect(
page.getByTestId("widget-message").filter({ hasText: "Lyon" }),
).toBeVisible();Performance
Bundle Size
| Format | Size (minified) | Size (gzipped) | Limit | | ------ | --------------- | -------------- | ----- | | UMD | ~84 KB | ~24 KB | 50 KB | | ESM | ~112 KB | ~26 KB | - |
CI enforces the 50 KB gzipped limit via size-limit.
Largest Contentful Paint (LCP)
Target: < 1.5s
The widget loads as an iframe, so LCP depends on:
- SDK download (~16 KB gzipped) - typically < 100ms
- Iframe creation - instant
- Embed page load - varies by network
Measured baselines
| Environment | Device | LCP | Notes |
| ----------------------- | ------------ | ------------ | ----------------------------------------------------------------------------------------------------- |
| Localhost (no throttle) | Desktop | 0.14s | Reference for development. Not representative of real visitor conditions — production target is 1.5s. |
| Marketing demo, Fast 4G | Desktop | to measure | Lighthouse against https://<marketing>/fr/demo/real-estate, throttle "Fast 4G", incognito. |
| Marketing demo, Slow 3G | Mobile (mid) | to measure | Same URL, Lighthouse mobile preset, throttle "Slow 3G". |
| Marketing demo, Fast 4G | Mobile (mid) | to measure | Same URL, Lighthouse mobile preset, throttle "Fast 4G". |
The three "to measure" rows are reserved for the next manual measurement pass against the marketing demo in prod. They are kept visible here so a visitor of this README sees what is covered and what is not, rather than a single localhost number that paints an over-optimistic picture.
How to measure LCP yourself (Lighthouse)
Reproducible manual procedure on any deployment that embeds the widget (staging, prod demo, or your own integration). The marketing demo is the canonical reference.
- Open Chrome in incognito (no extensions, no cache, no service worker from a previous visit interfering with the measurement).
- Go to
https://<marketing-host>/fr/demo/real-estate(or your own page embedding@dolard.eu/versiq-widget). - Open DevTools → Lighthouse.
- Select Performance only, choose the Mode = Navigation.
- Pick the device preset (Mobile or Desktop) and the throttling profile
(
Slow 4G,Fast 4G, orSlow 3G— match the row you want to fill in the table above). - Click Analyze page load.
- Read the Largest Contentful Paint metric from the report (top-left block, color-coded green/orange/red against the 2.5s "good" threshold).
Run each scenario 3 times and report the median to absorb cold-cache / network jitter. A single run is not a measurement, it's an anecdote.
Continuous measurement (production sites)
For integrators who want to keep an eye on the metric over time without running Lighthouse manually:
- Install the Web Vitals Chrome extension for a live LCP overlay while browsing.
- Wire web-vitals into your analytics pipeline (Google Analytics 4, Sentry, etc.) to track field LCP per real visitor — Lighthouse alone only measures synthetic loads.
The Versiq widget itself does not emit web-vitals events (out of scope — the SDK should not impose an analytics dependency on the host). Integrators own the measurement pipeline for their own page.
Integration Time
Target: < 15 minutes from docs to working widget
The Quick Start section provides copy-paste integration in under 5 minutes.
Browser Support
The SDK ships modern ESM and UMD bundles and targets evergreen browsers:
| Browser | Supported | | ------------------------ | --------------------- | | Chrome / Edge (Chromium) | last 2 major versions | | Firefox | last 2 major versions | | Safari (macOS / iOS) | last 2 major versions | | Internet Explorer | not supported |
The widget relies on <iframe>, postMessage, WeakSet, and fetch — all
available in every browser above. For the Node.js toolchain (bundlers, SSR
hosts), engines.node is >=18.
Changelog
Release notes are maintained automatically by release-please — see
CHANGELOG.md. Versions are bumped lockstep with
@dolard.eu/versiq-core-types.
Support
- Integration help & bug reports: open an issue.
- Commercial licensing & contracts: [email protected]
License
Commercial Source-available — see LICENSE.
This is not an open-source release: the source is published so integrators can audit what runs in their visitors' browsers, but fork / redistribution / competing-SDK use are prohibited without a written agreement. Production use against the Versiq backend is governed by the Versiq Commercial Terms of Service.
For commercial licensing inquiries: [email protected]
