# Help AI Elements: the full SDK docs (@helpai/elements@1.15.2) # Quickstart From zero to working chat widget in under 5 minutes. --- ## 1. Get your browser deployment credentials 1. Sign up at [help.ai](https://help.ai) if you haven't. 2. Go to **Dashboard → Deployments**. 3. Click **New deployment** — give it a name (e.g. "Marketing site"). 4. Copy the public key (`agent_pk_abc123...`) and deployment UUID from the generated install snippet. > The dashboard generates a copy-paste snippet for you — feel free to use > that directly. The rest of this guide assumes you want to know what > each piece does. --- ## 2. Pick your install path The widget works four ways. Pick whichever matches your site: - **Static HTML** (marketing site, WordPress, landing page) → [path A](#path-a--static-html). - **Async-loading site** (uses Google Tag Manager, Segment, etc.) → [path B](#path-b--async-loader). - **React / Next.js / Remix** → the React kit in [path C](#path-c--npm). - **Vue / Svelte / Nuxt** (any framework with a build step) → [path C](#path-c--npm). - **Plain HTML but prefer a declarative tag** → [path D](#path-d--web-component). All four paths land on the same widget with the same behavior. Mix and match as needed. --- ## Path A — static HTML Paste this into your ``: ```html ``` That's it. Reload your page and look at the bottom-right corner. **What `defer` does** — the script downloads in the background and runs after the HTML parses. Zero impact on first contentful paint. Always use `defer`. **Available attributes** — every config option is available as a kebab-case `data-*` attribute. Common ones: ```html ``` Full list: [Installation § script data attributes](https://cdn.help.ai/sdk/js/elements/1.15.2/docs/installation.md#path-a--script-data--auto-boot). --- ## Path B — async loader If your site already uses a tag-manager-style loader pattern (Google Analytics, Segment, Intercom, etc.), you'll recognize this shape: ```html ``` **What this does:** 1. Defines `window.HelpAI` as a queue until the real bundle loads. 2. Kicks off the async script download. 3. Queues an `init` call — it'll run as soon as the bundle is ready. You can stack more queued calls: ```js HelpAI('init', { publicKey: 'agent_pk_...', aiAgentDeploymentId: '01900000-0000-7000-8000-000000000000' }); HelpAI('open'); // auto-opens the panel once init finishes ``` Same behavior as path A, just split into two script tags so you can load asynchronously. --- ## Path C — NPM For sites with a build step (Next.js, Vite, Remix, Nuxt, SvelteKit, Astro…): ```bash npm install @helpai/elements # or: yarn add @helpai/elements # or: pnpm add @helpai/elements # or: bun add @helpai/elements ``` ### React, Next.js, Remix The React kit renders on the server safely and is already `'use client'`; put it in the root layout: ```tsx // app/layout.tsx import { HelpAI } from '@helpai/elements/react'; export default function RootLayout({ children }: { children: React.ReactNode }) { return ( {children} ); } ``` `useHelpAI()` in any component returns `{ state, ask, open, close, reset }`. ### Vue / Svelte / Angular / any bundler Call `init(...)` once on mount and `destroy()` on unmount, or use the `` element (path D). Full framework-by-framework walkthrough: [Framework integrations](https://cdn.help.ai/sdk/js/elements/1.15.2/docs/frameworks.md). --- ## Path D — web component Declarative tag that works in any framework or plain HTML: ```html ``` Attributes mirror the options. Changing an attribute at runtime re-renders the widget with the new value. Web components shine for: - In-app help centers (`mode="inline"` inside a sidebar). - CMS-built pages where you can paste HTML but not run JS. - Declarative "just put the widget here" integrations. --- ## 3. Verify it's working Open your page and look at the bottom-right. You should see a small round chat bubble with an accent color. - **Click it** → a panel opens with a greeting from the assistant. - **Type a message** → the assistant replies token-by-token. - **Close the panel** → the bubble returns. - **Reload the page** → your chat history stays intact. Something not showing up? → [Troubleshooting](https://cdn.help.ai/sdk/js/elements/1.15.2/docs/troubleshooting.md). --- ## 4. Common next steps - **Match your brand** — [Theming](https://cdn.help.ai/sdk/js/elements/1.15.2/docs/theming.md) has every color, radius, and font knob. - **Change the launcher label / icon** — [`launcher` options](https://cdn.help.ai/sdk/js/elements/1.15.2/docs/configuration.md#launcher). - **Know who the visitor is** — pass `userContext: { id, email, name }` (and `pageContext: { area }`) for personalization. - **Forward chat events to analytics** — [Events & hooks](https://cdn.help.ai/sdk/js/elements/1.15.2/docs/events-and-hooks.md). - **Add a second widget** (one for sales, one for support) — [Recipes § Multiple widgets](https://cdn.help.ai/sdk/js/elements/1.15.2/docs/recipes.md#multiple-widgets-on-one-page). --- ## TypeScript users Types ship with the NPM package — no separate `@types` install needed: ```ts import { init, mount, destroy, hook, type ChatWidgetOptions, type ChatWidgetInstance, type EventMap, type EventName } from '@helpai/elements'; ``` Every option is autocomplete-friendly. Hover a field in your IDE to see its doc. --- ## Environment variables For server-rendered apps, expose the public key and deployment ID through the framework's public environment variables. The values are designed for browser installation; the backend accepts the authenticated pair only when both match the same deployment: | Framework | Env var prefix | | ----------------------- | ---------------------- | | Next.js | `NEXT_PUBLIC_*` | | Vite / SvelteKit / Nuxt | `VITE_*` or `PUBLIC_*` | | Remix | read from `window.ENV` | | Create React App | `REACT_APP_*` | Full security notes: [Security & privacy](https://cdn.help.ai/sdk/js/elements/1.15.2/docs/security-privacy.md). --- # Installation Deep-dive for every install path. See [Quickstart](https://cdn.help.ai/sdk/js/elements/1.15.2/docs/quickstart.md) for the 5-minute version. --- ## Choose your path | Path | Best for | Delivery | | ----------------------------------------------------------------- | --------------------------------------------------- | ---------- | | [A. ` ``` ### Attributes The zero-JavaScript path intentionally exposes a small, flat subset of `ChatWidgetOptions`. These are the attributes parsed by the current bundle: | Attribute | Maps to | Example | | ---------------------------------------------- | --------------------------------- | ------------------------------- | | `data-public-key` | `publicKey` | `agent_pk_...` | | `data-ai-agent-deployment-id` | `aiAgentDeploymentId` | deployment UUID | | `data-base-url` | `baseUrl` | `https://help.ai` | | `data-agent-api-base-url` | `agentApiBaseUrl` | `http://localhost:3069` | | `data-api-base-url` | `dataApiBaseUrl` | `http://localhost:3071` | | `data-theme` | `theme` | `auto` / `light` / `dark` | | `data-mode` / `data-position` | `presentation` | `floating` / `bottom-right` | | `data-iframe` | `presentation.iframe` | `true` | | `data-width` / `data-height` | `presentation.size` | `420px` / `640px` | | `data-response-mode` | `behavior.responseMode` | `streaming` / `buffered` | | `data-start-minimized` | `behavior.startMinimized` | `false` | | `data-show-reasoning` | `behavior.showReasoning` | `true` | | `data-show-tool-calls` | `behavior.showToolCalls` | `true` | | `data-show-sources` | `behavior.showSources` | `true` | | `data-disable-message-feedback` | `behavior.disableMessageFeedback` | `true` | | `data-auto-send-suggestions` | `behavior.autoSendSuggestions` | `true` | | `data-pop-out-url` | `behavior.popOutUrl` | `https://chat.example.com` | | `data-locale` | `i18n.locale` | `fr-FR` | | `data-launcher-variant` / `-size` / `-icon` | `launcher` fields | `pill` / `lg` / `spark` | | `data-launcher-label` / `-hidden` | `launcher` fields | `Ask AI` / `true` | | `data-callout-text` / `-delay` | `launcher.callout` | `Hi! 👋` / `2000` | | `data-actions` | `header.actions` (CSV) | `theme,history,close` | | `data-features-disable-file-upload` | `features.disableFileUpload` | `true` | | `data-features-voice` | `features.voice` | `local` / `server` / `disabled` | | `data-endpoint-upload` / `-transcribe` | `endpoints` | `/pai/upload` | | `data-user-id` / `-email` / `-name` | `userContext` scalar fields | `user_42` | | `data-user-context` | `userContext` extras (JSON) | `'{"plan":"pro"}'` | | `data-page-area` / `-url` / `-path` / `-title` | `pageContext` scalar fields | `support` | | `data-page-context` | `pageContext` extras (JSON) | `'{"experiment":"b"}'` | Unknown attributes are ignored. Full option semantics: [Configuration](https://cdn.help.ai/sdk/js/elements/1.15.2/docs/configuration.md). > Explicit scalar attributes (`data-user-id`, `data-page-area`, …) win > over keys in the `data-user-context` / `data-page-context` JSON. For > SPAs where the visitor logs in after load or navigates between pages, > set context dynamically with > [`instance.setUserContext(...)` / `setPageContext(...)`](https://cdn.help.ai/sdk/js/elements/1.15.2/docs/configuration.md#setting-context-after-login--on-navigation). > The `data-user-*` / `data-page-*` attributes are reactive — updating > one re-handshakes with the same anonymous visitor/conversation. This > context is informational for the AI, never an auth credential. ### Load without mounting The script tag mounts the widget only when it carries `data-ai-agent-deployment-id`. Leave that attribute out to load the bundle **without** mounting, then call `init()` from JavaScript when you're ready: ```html ``` ### Multi-widget For two widgets on one page, load the bundle once and call `mount()` with distinct `widgetId` values: ```js HelpAI.mount({ widgetId: 'support', publicKey: SUPPORT_KEY, aiAgentDeploymentId: SUPPORT_DEPLOYMENT_ID }); HelpAI.mount({ widgetId: 'sales', publicKey: SALES_KEY, aiAgentDeploymentId: SALES_DEPLOYMENT_ID }); ``` --- ## Path B — async loader snippet Tag-manager-style snippet. Defers the bundle download without blocking first paint, while letting your page queue commands immediately. ```html ``` ### Queueable commands | Call | What it does | | ----------------------------------------- | ---------------------------------------------------------------- | | `HelpAI('init', opts)` | Mount once; update the same widget id | | `HelpAI('mount', opts)` | Mount fresh; replace the same widget id | | `HelpAI('destroy')` | Unmount all live widgets | | `HelpAI('open')` | Open the default widget's panel | | `HelpAI('close')` | Close the default widget's panel | | `HelpAI('expand')` | Toggle the default widget's expanded tier | | `HelpAI('ask', text, { send })` | Open the chat with `text` in the composer; `send: true` sends it | | `HelpAI('on', eventName, fn)` | Subscribe to a default-widget event | | `HelpAI('update', { /* patch */ })` | Update the default widget's options | | `HelpAI('hook', widgetId, eventName, fn)` | Register a global lifecycle hook | ### Pre-boot hooks `window.HelpAIHooks` is a pre-boot queue for event listeners. Push arrays or objects to it before the bundle arrives: ```html ``` Full details: [Events & hooks](https://cdn.help.ai/sdk/js/elements/1.15.2/docs/events-and-hooks.md#global-lifecycle-hooks). ### After boot Once the bundle loads, `window.HelpAI` becomes a real API: ```js window.HelpAI.init(opts) window.HelpAI.destroy(widgetId?) window.HelpAI.getInstance(widgetId?) // returns handle or null window.HelpAI.getAllInstances() window.HelpAI.hook(widgetId, event, fn) window.HelpAI.open() / close() / expand() window.HelpAI.version // semver string window.HelpAI.brand // { id, displayName, cdnUrl, ... } ``` --- ## Path C — NPM package For sites with a bundler. Full framework-specific walkthroughs: [Framework integrations](https://cdn.help.ai/sdk/js/elements/1.15.2/docs/frameworks.md). ### Install ```bash npm install @helpai/elements # or yarn add @helpai/elements # or pnpm add @helpai/elements # or bun add @helpai/elements ``` ### Entry points The package ships two entries: ```ts // Main entry — everything except the web component. import { init, mount, destroy, hook } from '@helpai/elements'; // Sub-entry — registers as a custom element. import '@helpai/elements/web-component'; ``` Tree-shaking: the main entry has zero mount-on-import side effects. Unused exports are dropped by your bundler. Importing only types costs zero runtime bytes. ### Minimal React usage ```tsx import { init, type ChatWidgetInstance } from '@helpai/elements'; import { useEffect, useRef } from 'react'; export function Chat() { const instance = useRef(null); useEffect(() => { instance.current = init({ publicKey: import.meta.env.VITE_HELPAI_KEY, aiAgentDeploymentId: import.meta.env.VITE_HELPAI_DEPLOYMENT_ID, }); return () => instance.current?.destroy(); }, []); return null; // the widget renders its own DOM outside your tree } ``` ### Typed options Autocomplete for every option: ```ts import type { ChatWidgetOptions } from '@helpai/elements'; const opts: ChatWidgetOptions = { publicKey: 'agent_pk_...', aiAgentDeploymentId: '01900000-0000-7000-8000-000000000000', presentation: { mode: 'floating' }, theme: { accent: '#ff3e00' }, launcher: { variant: 'pill', label: 'Ask AI' }, // ... }; ``` ### SSR The widget is client-only. Never call `init()` on the server. In Next.js app router, wrap your component with `'use client'` (shown above). In Remix / SvelteKit, use the `onMount` / `useEffect` equivalent to defer init until the browser. --- ## Path D — Web Component Declarative tag. Works in any framework or plain HTML. ### Load + use ```html ``` ### Attributes Same parser fields as ` ``` Pinning means you control upgrade timing. The floating URL means you get new features + bug fixes automatically. Both are supported; most customers use the floating URL for marketing sites and pinned versions for mission-critical apps. --- ## Path E — Standalone ESM bundle (no build, no NPM install) For pages without a build step (plain HTML, MDX, sandbox demos, code playgrounds, AI-generated UIs), load the pre-bundled ESM file shipped with every release. `elements.esm.js` is fully self-contained — every dependency (preact, signals, streaming-markdown) is inlined. No bare specifiers, no import maps, no `+esm` magic. Drop into any page with ` ``` ### Web component (declarative tag) The web-component bundle self-registers `` on import: ```html ``` ### File catalog (every `dist//` ships these) | File | Purpose | Use with | | --------------------------------- | ------------------------------------------------------ | --------------------------------------- | | `elements.js` | IIFE bundle, defines `window.HelpAI` | Plain ` ``` - jsDelivr serves a pinned version as `Cache-Control: public, max-age=31536000, immutable` — cached for a year, global edge, free. - **Always pin an exact version** for production. `@latest` / ranges (`@0.56`) are mutable (jsDelivr revalidates ~daily) **and** auto-update — a published patch can change behavior under you. - Trade-off: the customer must **bump the version in their snippet to update**, and every release re-downloads the whole ~107 KB (one self-contained file). - Speed it up on pages you control: `` (and optionally `rel="modulepreload"`). ### Option 2 — your own CDN behind a stable URL (recommended; simplest for customers) Point a CDN (`cdn.help.ai`, a Cloudflare/CloudFront origin, …) at the built `dist//` assets and give customers **one URL that never changes**: ```html ``` You ship updates **behind** that stable URL — the customer's page is frozen. The caching rule is the whole trick: | Asset | `Cache-Control` | Why | | ------------------------------------- | -------------------------------------- | -------------------------------------------- | | The stable entry (`elements.js`) | `public, max-age=300, must-revalidate` | Short TTL so a deploy propagates in minutes. | | Content-hashed chunks (`*..js`) | `public, max-age=31536000, immutable` | Hash in the name → safe to cache forever. | Plus Brotli/Gzip, HTTP/2+3, and (optionally) `integrity="sha384-…"` SRI hashes. ### Automatic chunking (zero customer changes) The value of Option 2 is the **stable URL** above — the customer's snippet never changes and you own caching + region. Chunking is a further, _optional_ step on top (usually **not** worth it — see the caveat below). With **content-hashed chunk filenames served from a version-less path**, an unchanged chunk keeps the **same URL** across releases, so the browser reuses it — only the chunks that actually changed re-download. To get there: 1. Build ESM with code-splitting on (esbuild/tsdown `splitting: true`, `format: "esm"`) and `[hash]` filenames. The thin entry imports the chunks. 2. Add lazy `import()` boundaries at the **optional surfaces** — messenger tabs (Home/Help/News), forms/HITL, voice. esbuild emits each as its own chunk and hoists shared code (e.g. Preact) into a common chunk automatically. Core chat loads first → smaller first paint; each feature caches independently. 3. The customer keeps the single stable ` ``` ```vue ``` ### Web component approach Vue 3 supports custom elements natively. Register the web-component sub-entry, then use the tag: ```ts // main.ts import { createApp } from 'vue'; import '@helpai/elements/web-component'; import App from './App.vue'; const app = createApp(App); // Tell Vue to treat `web-ai-chat` as a custom element (not a Vue component) app.config.compilerOptions.isCustomElement = (tag) => tag === 'web-ai-chat'; app.mount('#app'); ``` ```vue ``` --- ## Nuxt 3 ```ts // plugins/web-ai-chat.client.ts import { init } from '@helpai/elements'; export default defineNuxtPlugin((nuxtApp) => { const chat = init({ publicKey: useRuntimeConfig().public.helpaiKey, aiAgentDeploymentId: useRuntimeConfig().public.helpaiDeploymentId, }); nuxtApp.hook('app:beforeMount', () => { // Widget already mounted by init(). This hook is just a reference. }); // Clean up on app tear-down (rarely triggered in SPA navigation) nuxtApp.hook('app:suspense:resolve', () => { // No-op — widget stays across navigations }); return { provide: { chat, }, }; }); ``` ```ts // nuxt.config.ts export default defineNuxtConfig({ runtimeConfig: { public: { helpaiKey: process.env.NUXT_PUBLIC_HELPAI_KEY, helpaiDeploymentId: process.env.NUXT_PUBLIC_HELPAI_DEPLOYMENT_ID, }, }, }); ``` Note the `.client.ts` suffix — ensures the plugin only runs in the browser. --- ## Svelte / SvelteKit **SvelteKit**: ```svelte ``` **Plain Svelte** (no SvelteKit): ```svelte
``` ### Web component in Svelte Svelte handles custom elements natively: ```svelte ``` --- ## Astro Astro is SSR-first; wrap the widget in a client directive: ```astro --- // src/components/Chat.astro const publicKey = import.meta.env.PUBLIC_HELPAI_KEY; const aiAgentDeploymentId = import.meta.env.PUBLIC_HELPAI_DEPLOYMENT_ID; --- ``` ```astro --- // src/layouts/Layout.astro import Chat from '../components/Chat.astro'; --- ``` Or use the web component (even simpler — no JS needed): ```astro --- import '@helpai/elements/web-component'; --- ``` --- ## Remix Remix (and React Router 7) render on the server, which the [React kit](#react) handles: pass the keys from the loader. ```tsx // app/root.tsx import { HelpAI } from '@helpai/elements/react'; import { Outlet, useLoaderData } from '@remix-run/react'; export async function loader() { return { helpaiKey: process.env.HELPAI_PUBLIC_KEY ?? '', helpaiDeploymentId: process.env.HELPAI_DEPLOYMENT_ID ?? '', }; } export default function App() { const { helpaiKey, helpaiDeploymentId } = useLoaderData(); return ( {helpaiKey && helpaiDeploymentId && } ); } ``` --- ## SolidJS ```tsx import { init, type ChatWidgetInstance } from '@helpai/elements'; import { onMount, onCleanup } from 'solid-js'; export function Chat() { let instance: ChatWidgetInstance | null = null; onMount(() => { instance = init({ publicKey: import.meta.env.VITE_HELPAI_KEY, aiAgentDeploymentId: import.meta.env.VITE_HELPAI_DEPLOYMENT_ID }); }); onCleanup(() => { instance?.destroy(); }); return null; } ``` Solid also supports custom elements natively: ```tsx import '@helpai/elements/web-component'; export default function App() { return ; } ``` --- ## Angular Angular needs `CUSTOM_ELEMENTS_SCHEMA` to accept the custom tag: ```ts // src/app/app.module.ts import { NgModule, CUSTOM_ELEMENTS_SCHEMA } from '@angular/core'; import '@helpai/elements/web-component'; @NgModule({ // ... schemas: [CUSTOM_ELEMENTS_SCHEMA], }) export class AppModule {} ``` ```html ``` For programmatic control: ```ts // chat.service.ts import { Injectable } from '@angular/core'; import { init, type ChatWidgetInstance } from '@helpai/elements'; import { environment } from '../environments/environment'; @Injectable({ providedIn: 'root' }) export class ChatService { private instance: ChatWidgetInstance | null = null; mount() { if (!this.instance) { this.instance = init({ publicKey: environment.helpaiKey, aiAgentDeploymentId: environment.helpaiDeploymentId }); } } destroy() { this.instance?.destroy(); this.instance = null; } open() { this.instance?.open(); } } ``` --- ## WordPress Add the snippet via the Theme Header/Footer plugin OR directly in your theme's `header.php`: ```html ``` ### Via a plugin Any "Insert Headers and Footers" plugin works: 1. Install the plugin (e.g., "Insert Headers and Footers" by WPBeginner). 2. Settings → Insert Headers and Footers. 3. Paste the script tag into the "Scripts in Header" box. 4. Save. ### Via functions.php ```php ID) { $attrs .= ' data-user-id="' . esc_attr($user->ID) . '"'; $attrs .= ' data-user-email="' . esc_attr($user->user_email) . '"'; $attrs .= ' data-user-name="' . esc_attr($user->display_name) . '"'; $attrs .= ' data-user-context=\'' . esc_attr(json_encode(array("plan" => "pro"))) . '\''; } ?> Note: `data-user-*` / `data-page-*` are host-asserted context for the > AI, never an auth credential — the browser deployment is selected by > `data-public-key` + `data-ai-agent-deployment-id`. Explicit > scalar attributes win over keys in the `data-user-context` / > `data-page-context` JSON. --- ## Shopify Shopify has a "custom pixels" and "additional scripts" workflow. Easiest path: 1. In your admin: **Online Store → Themes → Edit code**. 2. Open `theme.liquid`. 3. Paste before ``: ```html ``` ### With Shopify customer data ```html {% if customer %} {% else %} {% endif %} ``` --- ## Webflow Webflow lets you add site-wide custom code: 1. Site settings → **Custom code → Head code**. 2. Paste: ```html ``` 3. Save + publish. Page-specific customization: open the page → **Page settings → Inside `` tag** and paste a page-specific snippet. --- ## Squarespace / Wix **Squarespace:** 1. Settings → **Advanced → Code Injection**. 2. Paste the script tag into the **Header** section. **Wix:** 1. Settings → **Custom Code**. 2. Add new snippet. 3. Paste + choose "All pages" + "Head" placement. --- ## Framework-agnostic troubleshooting **The widget doesn't appear in SSR previews** — that's expected. The widget is client-only. It appears after hydration / mount. **The widget flashes open then closes on SPA navigation** — you're probably calling `destroy()` then `init()` on every route change. Use `init()` alone — it's idempotent. If there's an existing instance at the same `widgetId`, it merges the new options in. **Hot module replacement breaks the widget** — Next.js / Vite HMR sometimes mounts duplicate instances. Always pair your `useEffect` / `onMounted` with a cleanup that calls `destroy()`. **CSP blocks the widget** — the widget inlines styles inside a shadow root (requires `style-src 'unsafe-inline'` or a nonce pattern). See [Security & privacy § CSP](https://cdn.help.ai/sdk/js/elements/1.15.2/docs/security-privacy.md#content-security-policy). More: [Troubleshooting](https://cdn.help.ai/sdk/js/elements/1.15.2/docs/troubleshooting.md). --- ## Next - [Recipes](https://cdn.help.ai/sdk/js/elements/1.15.2/docs/recipes.md) — programmatic control patterns. - [Events & hooks](https://cdn.help.ai/sdk/js/elements/1.15.2/docs/events-and-hooks.md) — analytics integration. - [Configuration](https://cdn.help.ai/sdk/js/elements/1.15.2/docs/configuration.md) — every option in detail. --- # Configuration reference Every option you can pass to `init(...)` / `mount(...)` / set via `data-*` attributes / set via web-component attributes. Group-by-group walkthrough with realistic examples. For an at-a-glance lookup, see [API reference](https://cdn.help.ai/sdk/js/elements/1.15.2/docs/api-reference.md). --- ## How options arrive Options resolve in this order (highest priority wins): 1. **Options you pass to `init()`** — always win. 2. **Server-pushed settings** — your Help AI dashboard settings for this deployment. Fill in anything you left unset. 3. **Built-in defaults** — sensible fallbacks shipped with the widget. This means you can: - Hardcode `publicKey` in code, tune the rest from the dashboard. - Ship all config in `init()` and ignore the dashboard. - Override a specific field (e.g., force `theme: "dark"`) while letting the dashboard control everything else. ### Canonical option shape Embed identity, routing, context, endpoint overrides, storage, and callbacks stay at the top level. Serializable widget settings are grouped by ownership: | Section | Fields described in this guide | | -------------- | ------------------------------------------------------------------------------------ | | `presentation` | `mode`, `iframe`, `position`, `size` | | `behavior` | `startMinimized`, `responseMode`, transcript visibility, feedback, suggestion policy | | `header` | `actions` | | `composer` | `sendButton`, `attachments` | | `feedback` | `sound`, `haptics` | | `i18n` | `locale`, `defaultLocale`, `availableLocales`, `strings` | | `footer` | `composerDisclaimer`, `poweredBy` | The flat HTML attributes remain intentionally short (`data-mode`, `data-actions`, and so on); the attribute parser maps them into this nested JavaScript shape. --- ## Required ### `publicKey` `string` · required for authenticated browser deployments Your deployment's public key from the Help AI dashboard. Looks like `agent_pk_...`. ```ts init({ publicKey: 'agent_pk_abc123...', aiAgentDeploymentId: '01900000-0000-7000-8000-000000000000' }); ``` The public key authorizes the public widget surface. Pair it with that same deployment's `aiAgentDeploymentId`; the backend requires both values to match. Intentional guest mode omits the key but still supplies the deployment ID. There is no per-end-user token — see [`userContext`](#usercontext) below. ### `aiAgentDeploymentId` `string` · required for browser deployments The deployment UUID from the same Dashboard → Deployments install panel as the public key. It selects the exact agent/configuration and acts as a defense-in-depth match for authenticated mounts. Send it on every browser mount; also send the public key unless guest mode is intentional. --- ## Identity + routing ### `widgetId` `string` · default: `"default"` A name you pick for this widget instance. Matters when you run more than one widget on a page — each widgetId gets its own chat thread, settings, and storage. ```ts init({ widgetId: 'support', publicKey: SUPPORT_KEY, aiAgentDeploymentId: SUPPORT_DEPLOYMENT_ID }); init({ widgetId: 'sales', publicKey: SALES_KEY, aiAgentDeploymentId: SALES_DEPLOYMENT_ID }); ``` See [Recipes § Multiple widgets](https://cdn.help.ai/sdk/js/elements/1.15.2/docs/recipes.md#multiple-widgets-on-one-page). ### `baseUrl` `string` · default: `https://help.ai` The MAIN site origin the widget calls — the API modules derive from it (agent API at `${baseUrl}/api/agent`, data API at `${baseUrl}/api/data`). Override if you use: - A custom domain (`your-company.com`). - A local dev backend. ### `userToken` `string | () => string | Promise` · default: none (anonymous) Signs the visitor in as your logged-in user: a JWT your server signs with the deployment's secret key, or a function returning a fresh one. Their conversations then follow them to any device, and the AI gets their name and email as verified facts. Runtime-only: not an attribute or `data-*` option, so the token stays out of the page's markup. See [Security → Verified users](https://cdn.help.ai/sdk/js/elements/1.15.2/docs/security-privacy.md#verified-users-usertoken) for signing it. ```ts init({ publicKey: 'agent_pk_...', aiAgentDeploymentId: '01900000-0000-7000-8000-000000000000', userToken: () => fetch('/api/help-ai-token').then((res) => res.text()) }); ``` ### `tools` `ModelContextTool[]` · default: none Tools the AI agent can use on your page, in the WebMCP tool shape: a name, a description, a JSON Schema of its input and an `execute` function the widget runs in the visitor's browser when the agent calls it (`annotations: { consequentialHint: true }` asks the visitor first). Runtime-only, like `userToken`; `chat.update({ tools })` replaces them. `chat.registerTool(tool)` adds one for every widget on the page instead. Full guide: [Page tools](https://cdn.help.ai/sdk/js/elements/1.15.2/docs/page-tools.md). ```ts init({ publicKey: 'agent_pk_...', aiAgentDeploymentId: '01900000-0000-7000-8000-000000000000', tools: [{ name: 'open_booking', description: 'Open the booking form. Use it whenever the visitor wants to book a call.', execute: () => (bookingForm.open(), { opened: true }) }], }); ``` ### `userContext` `object | undefined` Host-asserted information about the end user, passed to the AI as **context only — never an authentication credential.** The backend does not register or authenticate these users. Optional — omit it for anonymous chats. Recommended for logged-in users so the AI has context. ```ts init({ publicKey: 'agent_pk_...', aiAgentDeploymentId: '01900000-0000-7000-8000-000000000000', userContext: { id: 'user_42', // your user id email: 'jane@help.ai', name: 'Jane Doe', plan: 'pro', role: 'admin', phone: '+15551234567', seats: 12, // arbitrary custom scalar field }, }); ``` A flat object. Recognised fields (all optional): - `id` — your user's stable identifier. - `email` — for personalization. - `name` — for personalization (also drives the Home tab greeting, e.g. "Hi Jane 👋"). - `plan` — subscription / pricing tier (e.g. `"pro"`). - `role` — the user's role on your site (e.g. `"admin"`). - `phone` — phone number. Plus any arbitrary custom scalar fields (`string | number | boolean`) — account age, feature flags, etc. Because this is untrusted external input, it is sanitized client-side before it leaves the page: non-scalar values are dropped, reserved keys (`visitorId`, `conversationId`, `conversationId`, `jwt`, `token`, `authorization`, `password`) are stripped, a malformed `email` is dropped, strings are trimmed and capped at 1024 chars, max 50 keys. Sanitization warns to the debug log and never throws. > **This is YOUR site's logged-in user — not the widget's own visitor.** > The widget independently mints and persists an anonymous > `visitorId` / `conversationId` for the device; `userContext` never overrides > those. Setting `userContext` simply attaches your context to the same > visitor. ### `pageContext` `object | undefined` Host-asserted context about the page/visit (separate from `userContext`), so the AI can adapt to where the visitor is — a pricing visitor is not a docs reader is not a help-desk ticket. ```ts init({ publicKey: 'agent_pk_...', aiAgentDeploymentId: '01900000-0000-7000-8000-000000000000', pageContext: { area: 'pricing', // host-defined surface path: '/pricing/enterprise', title: 'Enterprise plans', // url auto-filled from location.href when omitted }, }); ``` Recognised fields (all optional): - `area` — host-defined surface the visitor is on. Free-form string; suggested values: `home`, `docs`, `help-desk`, `support`, `pricing`, `checkout`, `orders`, `account`, `blog` (any string accepted). - `url` — full page URL. Auto-filled from the host page's `location.href` when omitted. - `path` — page path, e.g. `/pricing/enterprise`. - `title` — page title. Plus arbitrary custom scalar fields. Sanitized client-side with the same rules as `userContext`. > **On the wire:** `userContext` + `pageContext` are bundled into one > `context` field — `base64url(JSON.stringify({ user, page }))` — sent on > **every** request (body for POSTs, `?context=` for GETs), so the backend > always has the visitor's latest context as they navigate. base64url is > encoding, **not encryption** (the transport is already TLS); it just keeps > PII out of plaintext access logs / the devtools network tab. ### Setting context after login / on navigation In a typical SPA the visitor is anonymous on first load and logs in later. Update context dynamically via the instance handle. The new context rides the **next** request automatically (no reconnect) — and a `setUserContext` identity change additionally re-personalizes the greeting, keeping the **same** visitor/conversation so the conversation is continuous: ```ts const widget = HelpAI.init({ publicKey: 'agent_pk_...', aiAgentDeploymentId: '01900000-0000-7000-8000-000000000000' }); // after your app authenticates the user: widget.setUserContext({ id: 'user_42', email: 'jane@help.ai', name: 'Jane Doe', plan: 'pro', }); // on SPA navigation: widget.setPageContext({ area: 'checkout', path: '/checkout' }); // on logout: widget.clearUserContext(); ``` You can also seed context at load time via `data-user-*` / `data-page-*` tag attributes — see [Installation](https://cdn.help.ai/sdk/js/elements/1.15.2/docs/installation.md) and the framework recipes in [`frameworks.md`](https://cdn.help.ai/sdk/js/elements/1.15.2/docs/frameworks.md). --- ## Rendering ### `presentation.mode` `"floating" | "inline" | "standalone" | "modal" | "drawer" | "page"` · default: `"floating"` How the widget renders on the page. - **`"floating"`** (default) — floating chat bubble in a corner. Clicking opens the panel. Best for marketing sites, support chat. - **`"inline"`** — a block element that fills its parent container. No bubble, no popover. Best for in-app assistants, sidebar chats. - **`"standalone"`** — full viewport. The dedicated chat tab; renders the full module surface (no sidebar). - **`"modal"`** — a centered dialog over a backdrop. The host opens it (no launcher); call `open()` from your own trigger. - **`"drawer"`** — docks to a screen edge; the launcher becomes a vertical edge tab. - **`"page"`** — full-viewport split: a conversation **sidebar** plus the module surface in the main area. `target` controls where every mode's shadow host is inserted. It is most important for `inline` / `standalone` / `page`; overlay modes remain viewport-positioned even when their host is inserted under a custom target. See `../features/modes.md` for the full behavior matrix. ```ts init({ presentation: { mode: 'inline' }, target: document.getElementById('chat')! }); ``` ### `target` `HTMLElement | undefined` Where to insert the widget's shadow host. Recommended for `inline` / `standalone` / `page` modes. Floating, drawer, and modal hosts also use it, while their panels remain viewport-positioned. Defaults to ``. ### `presentation.iframe` `boolean` · default: `false` Wrap the widget in a same-origin iframe for strict JS + CSS isolation. Useful when the host page has aggressive global scripts that mangle `window.*` or inject hostile CSS. ```ts init({ presentation: { mode: 'floating', iframe: true }, publicKey: 'agent_pk_...', aiAgentDeploymentId: '01900000-0000-7000-8000-000000000000' }); ``` Composes with any `mode`. Default is `false` — shadow DOM alone is enough for 95% of deployments. [Security § Iframe isolation](https://cdn.help.ai/sdk/js/elements/1.15.2/docs/security-privacy.md#iframe-isolation). ### `presentation.position` `"bottom-right" | "bottom-left" | "top-right" | "top-left"` · default: `"bottom-right"` Launcher corner. Only applies in `floating` mode. ```ts init({ presentation: { position: 'bottom-left' } }); ``` ### `behavior.startMinimized` `boolean` · default: `true` Whether the floating panel starts closed (showing only the launcher). Only applies in `floating` mode. ```ts // Start open on every fresh page load init({ behavior: { startMinimized: false } }); ``` User's last open/close choice is remembered per device — so even with `behavior.startMinimized: false`, closing the widget and reloading keeps it closed. --- ## Theming ### `theme` `"auto" | "light" | "dark" | ThemeOverrides` · default: `"auto"` Three forms: **1. Preset mode:** ```ts init({ theme: 'auto' }); // follows OS prefers-color-scheme init({ theme: 'light' }); // force light init({ theme: 'dark' }); // force dark ``` **2. Color overrides (stays in auto mode):** ```ts init({ theme: { accent: '#ff3e00', // primary brand color accentText: '#ffffff', // text on the accent color radius: '12px', // corner rounding fontFamily: 'Inter, system-ui, sans-serif', }, }); ``` **3. Both at once** — not supported in one call, but users can pick a mode via the header picker regardless of palette overrides. End users can flip the theme via the header picker. Their choice is persisted per device + synced cross-device. Full customization guide: [Theming](https://cdn.help.ai/sdk/js/elements/1.15.2/docs/theming.md). --- ## Sizing ### `presentation.size` `SizeOptions | undefined` Control the panel's dimensions across three tiers: ```ts init({ presentation: { size: { // Normal tier (default panel size) width: '420px', height: '640px', // Expanded tier (when user clicks the ⤢ button) expanded: { width: '80vw', height: '80vh' }, // User drag-resize resize: { enabled: true, minWidth: '360px', maxWidth: '900px', minHeight: '480px', maxHeight: '90vh', }, // First-render pick initialSize: 'auto', // auto / normal / expanded / fullscreen autoSizeBreakpoint: 900, // "auto" → expanded if viewport ≥ 900px }, }, }); ``` ### Size tiers - **Normal** — `width`/`height` or mode-specific CSS defaults. - **Expanded** — ⤢ header button, configurable dimensions. - **Fullscreen** — ⛶ header button, always 100vw × 100vh. ### Drag-resize `presentation.size.resize.enabled: true` shows a corner grip on the panel. Users drag it to resize; dimensions persist per device + sync cross-device. `presentation.size.resize.minWidth` etc. clamp the drag range. ### `presentation.size.initialSize` Which tier to open at on the user's **first** mount. After that, user interactions own the size. - `"normal"` — force the normal tier. - `"expanded"` — open roomy. - `"fullscreen"` — open full-screen. - `"auto"` (default) — `fullscreen` below 640 px, `normal` from 640 px to `autoSizeBreakpoint`, then `expanded` on wider viewports. --- ## Launcher ### `launcher` `LauncherOptions | undefined` · only applies in `presentation.mode: "floating"` The FAB and its nudges. ```ts init({ launcher: { variant: 'pill', // circle / pill / bar / minimal size: 'md', // sm / md / lg label: 'Ask us anything', icon: 'chat', // chat / message / bubbles / spark / wand / bot / help / lifebuoy / headset / search / sparkles / concierge / wave / raw SVG hidden: false, // true = no FAB // Unified pre-engagement bubble (optional). Same component // for greetings, nudges, onboarding tips — pick the // visuals + dismissal lifecycle for the use case. callout: { text: '👋 Hi! Need any help?', shape: 'pill', // pill / bubble / callout position: 'auto', // auto / horizontal / vertical delayMs: 1200, autoDismissMs: 0, animated: true, persistent: true, // true (default) = re-show on every visit // false = dismiss-once, hidden across visits }, }, }); ``` Variants + callout fully explained in [Theming § Launcher](https://cdn.help.ai/sdk/js/elements/1.15.2/docs/theming.md#launcher). ### `launcher.hidden` Hide the launcher FAB entirely. Use when you want to trigger the widget from your own UI: ```ts const chat = init({ launcher: { hidden: true } }); document.getElementById('my-custom-chat-button').onclick = () => chat.open(); ``` --- ## Header actions ### `header.actions` `ActionName[]` · per-mode defaults The list is a flat enabled set. `history` and `close` render as dedicated buttons; the rest live in the ⋯ menu in a fixed internal order. Unlisted actions are hidden, so input order does not choose placement. ```ts init({ header: { actions: ['theme', 'textSize', 'history', 'close', 'clear', 'fullscreen', 'popOut', 'sound', 'language'], }, }); ``` Available actions: | Name | What it does | | ------------ | --------------------------------------------------------------------------- | | `clear` | Start a new conversation (trash icon) | | `expand` | Toggle normal ↔ expanded tier (⤢) | | `fullscreen` | Toggle normal ↔ fullscreen (⛶) | | `popOut` | Open the active conversation in `behavior.popOutUrl` | | `sound` | Toggle notification sounds (switch-style) | | `history` | Toggle the chat history pane | | `language` | Open the language picker (renders as globe icon or inline rows in overflow) | | `theme` | Open the theme picker (auto/light/dark) | | `textSize` | Cycle the conversation text size | | `close` | Close the panel (X) | `popOut` renders only when `behavior.popOutUrl` is configured. The new tab receives `chat`, `widgetId`, `public_key` (when present), and `ai_agent_deployment_id` query parameters so it can resume the same thread. Per-mode defaults and visibility rules are maintained in Header actions. > `header.actions` is always a complete replacement for the per-mode default; > arrays are never merged. An explicit client list wins over the server list. --- ## Composer ### `composer.sendButton` Send-button chrome. ```ts init({ composer: { sendButton: { shape: 'pill', // circle / square / pill icon: 'arrowUp', // arrowUp / paperPlane / send / chevron variant: 'filled', // filled / outline / ghost }, }, }); ``` 3 shapes × 4 icons × 3 variants = 36 combinations. Pick what matches your brand. --- ## Footer ### `footer.composerDisclaimer` Small grey text below the textarea. Classic "AI can make mistakes" pattern. ```ts init({ footer: { composerDisclaimer: 'AI can make mistakes. Check important info.' } }); ``` Empty / omitted = no disclaimer shown. ### `footer.poweredBy` Attribution row below the disclaimer. Defaults to the active brand: `Powered by ` linking to `BRAND.siteUrl` (e.g. `Powered by Help AI` → `https://help.ai` for `@helpai/elements`). Override fields per-deployment when you want different copy or to add a logo; omit fields to inherit the brand defaults. ```ts init({ footer: { poweredBy: { text: 'Powered by Acme', // overrides brand default href: 'https://acme.com', // overrides brand default logoUrl: 'https://yoursite.com/helpai-logo.svg', }, }, }); ``` To opt out of the attribution entirely, pass an empty `text` (and no logo): `footer: { poweredBy: { text: "" } }` collapses the element. --- ## Attachments + features ### `composer.attachments` Client-side limits. Server enforces its own — these are to give users fast feedback before upload. ```ts init({ composer: { attachments: { maxBytes: 10 * 1024 * 1024, // 10 MB, default maxCount: 10, // default accept: 'image/*,application/pdf', // comma-separated MIME globs }, }, }); ``` ### `features.disableFileUpload` `boolean` · default: `false` Turn OFF file uploads — hide the paperclip button and reject drag-drop / paste. Unset/`false` = enabled (default); set `true` to disable attachments entirely. ### `features.enableOperatorRequest` `boolean` · default: `false` Opt IN **operator control** — exposes a "Talk to a person" control in the composer and the connected-to-operator states (a status banner while the conversation is routed to the operator, and operator reply bubbles). This is a **positive opt-in** for a new capability (unlike the `disable*` flags): unset/`false` = off (default), `true` = on. Every deployment holds one long-lived `GET /pai/stream-resume`; this flag does not gate the stream. It only exposes the visitor's `request-operator` control. AI replies, operator replies, and control-state changes continue to arrive on the resume channel and deduplicate by message id whether the request control is visible or not. The composer stays open while operator-routed so the visitor can keep typing. Operator requests require backend support. ```ts init({ features: { enableOperatorRequest: true } }); ``` ### `features.voice` `"local" | "server" | "disabled"` · default: `"local"` How voice input works: - `"local"` — uses the browser's Web Speech API. Zero upload, free, quality varies by browser. - `"server"` — records audio via `MediaRecorder`, uploads to `/pai/transcribe-audio`, receives text. Consistent quality, uses your upload quota. - `"disabled"` — hides the mic button entirely. ## Messenger modules ### `modules` Turns the panel into an Intercom-style **messenger** with a bottom tab bar. `modules` is an **ordered array of up to 4 tabs**; each tab picks a UI `layout`, the content collections it shows (`contentCollectionIds`), and a translatable `label`. The surface renders in **every** mode (in `page` mode it fills the main area beside the sidebar). With nothing set, just the **chat** shows — today's single-chat widget, no tab bar. ```ts init({ publicKey: 'agent_pk_...', aiAgentDeploymentId: '01900000-0000-7000-8000-000000000000', modules: [ { label: 'tabHome', layout: 'home', contentCollectionIds: [GUIDES_ID], greetingText: 'Hi {name} 👋', brandName: 'Acme' }, // "Popular articles" = the collections' pinned items { label: 'tabConversations', layout: 'chat' }, // the conversation { id: 'help', label: 'tabHelp', layout: 'help', contentCollectionIds: [GUIDES_ID, BILLING_ID] }, { id: 'news', label: 'tabNews', layout: 'news', contentCollectionIds: [NEWS_ID] }, ], }); ``` | Field | Required | Meaning | | ---------------------- | -------- | -------------------------------------------------------------------------------------------------- | | `label` | yes | Tab label — an i18n key (e.g. `tabHome`) or literal; translated via `i18n.strings`. | | `layout` | yes | `chat` (the conversation), `home`, `help`, or `news`. | | `contentCollectionIds` | no | Content collection ids (UUIDv7, max 20) this tab shows, in order; Help sections follow this order. | | `id` | no | Stable id for nav + last-tab memory (defaults to the first `contentCollectionIds`, else `layout`). | | home extras | no | `greetingText` (supports `{name}`), `hideSearchBar`, `brandName`, … (`home` layout only). | - **Order is the array order** (left → right). One tab → no tab bar; zero tabs (`[]`) → an empty state. - The first tab is the first-open default; the widget then remembers the user's last tab (per device). - The deployment can also push `modules` from the dashboard via the handshake (`config.modules`); your `init({ modules })` wins (replace-whole). Each tab's content is fetched lazily from `GET /content` (data-api) on first open. Full behavior + wire contract: `features/messenger-modules.md`. --- ## Response mode ### `behavior.responseMode` `"streaming" | "buffered"` · default: `"streaming"` - `"streaming"` — paint replies token-by-token as they arrive. Default. - `"buffered"` — show a neutral loading indicator until the reply finishes, then render it whole. The backend always streams; this is a client-rendering choice. Useful when token-by-token feels stuttery. ### `behavior.disableMessageFeedback` `boolean` · default: `false` Turn OFF the 👍/👎 feedback controls under each rateable reply (AI or operator) — unset/`false` shows them (default). While shown, a choice fires the [`messageFeedback`](https://cdn.help.ai/sdk/js/elements/1.15.2/docs/events-and-hooks.md) event and is sent to the backend. ```ts init({ behavior: { disableMessageFeedback: true } }); // hide the controls ``` ### `behavior.autoSendSuggestions` `boolean` · default: `false` Controls what tapping a suggestion chip does. Unset/`false` (default) **fills the composer** with the chip's text so the visitor can edit before sending; `true` **sends** it immediately. ```ts init({ behavior: { autoSendSuggestions: true } }); // send on tap instead of filling ``` --- ## Internationalization ### `i18n.locale` `string | undefined` Force a specific BCP-47 locale, overriding the visitor's browser setting. ```ts init({ i18n: { locale: 'fr-CA' } }); ``` ### `i18n.defaultLocale` `"auto" | string` · default: `"auto"` Initial / fallback locale when the user hasn't picked one. - `"auto"` — sniff `navigator.language`. - Explicit tag (e.g., `"es-MX"`) — force that one. ### `i18n.availableLocales` `string[]` · optional Locales offered in the language switcher. Omission defaults to the active locale as a one-entry list; zero or one entries hide the picker. ```ts init({ i18n: { availableLocales: ['en', 'fr', 'es', 'de'], defaultLocale: 'auto', }, }); ``` ### `i18n.strings` Per-key overrides for any UI string. Two shapes: **Flat** (applies to active locale): ```ts init({ i18n: { strings: { send: 'Go', composerPlaceholder: 'Ask me anything…', newConversation: 'Start fresh', }, }, }); ``` **Nested** (per-locale): ```ts init({ i18n: { strings: { en: { send: 'Go' }, fr: { send: 'Envoyer' }, es: { send: 'Enviar' }, }, }, }); ``` Full key list: [i18n](https://cdn.help.ai/sdk/js/elements/1.15.2/docs/theming.md#internationalization) or `src/i18n/strings.ts` in the source. --- ## Sound + haptics ### `feedback.sound` Notification sounds. ```ts init({ feedback: { sound: { enabled: true, // default volume: 0.3, // 0..1, default 0.3 events: { messageReceived: true, // built-in synth tone messageSent: true, error: 'https://cdn.your-site.com/error.mp3', // custom audio file voiceStart: false, // silence this one voiceStop: false, }, }, }, }); ``` Per-event values: - `true` — use the built-in synthesized tone. - `false` — silence this event. - `"url"` — URL to a custom audio file (decoded once + cached). End users can mute via the sound action in the header. Their choice is remembered. ### `feedback.haptics` Tactile feedback on touch devices. ```ts init({ feedback: { haptics: { enabled: true, // default: true on touch devices events: { messageSent: [6], // single 6ms tap error: [10, 50, 10], // two taps with a 50ms pause }, }, }, }); ``` Per-event values: - `true` — default pattern. - `false` — silence. - number — single vibration of N ms. - number[] — alternating `[vibrate, pause, vibrate, …]`. iOS: WebKit only supports a single tick; arrays are flattened. --- ## Callbacks (shortcut for events) Quick inline callbacks for common events. Same as calling `.on(...)` on the returned instance. ```ts init({ publicKey: 'agent_pk_...', aiAgentDeploymentId: '01900000-0000-7000-8000-000000000000', onMessage: (msg) => console.log(msg), onOpen: () => console.log('opened'), onClose: () => console.log('closed'), onError: (err) => Sentry.captureException(err), }); ``` For richer event handling, see [Events & hooks](https://cdn.help.ai/sdk/js/elements/1.15.2/docs/events-and-hooks.md). --- ## Storage ### `storage` `ChatWidgetOptions["storage"]` Custom synchronous storage adapter. Default is `localStorage`. Swap for: - `sessionStorage`. - Encrypted wrapper. - In-memory shim (for environments without `localStorage`). ```ts import type { ChatWidgetOptions } from '@helpai/elements'; type StorageAdapter = NonNullable; const myStorage: StorageAdapter = { get: (key) => sessionStorage.getItem(key), set: (key, value) => sessionStorage.setItem(key, value), remove: (key) => sessionStorage.removeItem(key), }; init({ publicKey: 'agent_pk_...', aiAgentDeploymentId: '01900000-0000-7000-8000-000000000000', storage: myStorage }); ``` The adapter also receives `draft.` keys containing exact unsent textarea text and owns their storage, retention, and privacy behavior. The widget itself does not include drafts in Help AI network requests before Send. Use an encrypted adapter when same-origin plaintext storage is not acceptable; an in-memory adapter disables reload restoration. Full adapter contract: [Security & privacy § custom storage](https://cdn.help.ai/sdk/js/elements/1.15.2/docs/security-privacy.md#custom-storage). --- ## Runtime changes You can change most options after mount via `chat.update(...)`: ```ts const chat = init({ publicKey: 'agent_pk_...', aiAgentDeploymentId: '01900000-0000-7000-8000-000000000000' }); // Later — flip to dark mode chat.update({ theme: 'dark' }); // Or change the launcher label chat.update({ launcher: { variant: 'pill', label: 'New message!' } }); ``` `update()` is a **shallow merge** at the top level. Nested objects are replaced, not merged: ```ts chat.update({ presentation: { size: { expanded: { width: '800px' } } } }); // This replaces the whole `presentation` section — mode, iframe, and the other size fields are gone! // Keep the current section in your application state and spread each nested level you preserve: chat.update({ presentation: { ...currentPresentation, size: { ...currentPresentation.size, expanded: { width: '800px' }, }, }, }); ``` The `publicKey`, `widgetId`, `presentation.mode`, and `presentation.iframe` can't change at runtime — they require a full remount (`destroy()` + `mount()`). --- ## Putting it all together — full example ```ts import { init } from '@helpai/elements'; const chat = init({ // Identity publicKey: process.env.NEXT_PUBLIC_HELPAI_KEY!, aiAgentDeploymentId: process.env.NEXT_PUBLIC_HELPAI_DEPLOYMENT_ID!, widgetId: 'support', userContext: { id: currentUser.id, email: currentUser.email, name: currentUser.name }, pageContext: { area: 'support', path: location.pathname }, // Presentation + behavior presentation: { mode: 'floating', position: 'bottom-right', size: { expanded: { width: '720px', height: '85vh' }, resize: { enabled: true, minWidth: '360px', maxWidth: '900px' }, initialSize: 'auto', autoSizeBreakpoint: 1024, }, }, behavior: { startMinimized: true }, theme: { accent: '#ff3e00', radius: '12px' }, // Launcher launcher: { variant: 'pill', label: 'Ask us anything', icon: 'chat', callout: { text: 'Hey there 👋', delayMs: 2000 }, }, // i18n i18n: { availableLocales: ['en', 'fr', 'es'], defaultLocale: 'auto', strings: { en: { composerPlaceholder: 'Ask about anything…' }, fr: { composerPlaceholder: 'Demandez-moi…' }, }, }, // Feedback feedback: { sound: { enabled: true, volume: 0.25 } }, // Footer footer: { composerDisclaimer: 'AI-generated answers may contain mistakes.', poweredBy: { text: 'Powered by Help AI', href: 'https://help.ai' }, }, // Events onMessage: (m) => analytics.track('chat_message', m), onError: (err) => Sentry.captureException(err), }); ``` --- ## Not covered here - **Events** → [Events & hooks](https://cdn.help.ai/sdk/js/elements/1.15.2/docs/events-and-hooks.md) - **Theming deep-dive** → [Theming](https://cdn.help.ai/sdk/js/elements/1.15.2/docs/theming.md) - **Framework-specific wiring** → [Frameworks](https://cdn.help.ai/sdk/js/elements/1.15.2/docs/frameworks.md) - **Backend API** → Backend contract (internal) --- # Configuration — `ChatWidgetOptions` The canonical surface lives in `src/schema/widget.ts` and `src/schema/deployment.ts` as Zod schemas. Every field carries a `.describe(...)` string and (where useful) `.meta({ examples })` — this page is a flat reference auto-generated from those annotations. When the schema and this doc disagree, **the schema wins**. Run `bun run gen:docs` after editing a schema to refresh the tables; CI fails if this file is out of sync. For longer-form prose on what each subsystem does, see the `features/` directory: - Theming - Sizing - Launcher - Header actions - Composer - Feedback - i18n - Modes --- ## Conventions - Every field is optional unless its row says otherwise. - "Default" is what the runtime applies when the field is absent. `—` means there's no schema-level default; the resolver falls through to runtime behaviour or leaves the field undefined. - Field names use the schema (camelCase) form. The web-component + ` ``` Full attribute list: [Installation § script data attributes](https://cdn.help.ai/sdk/js/elements/1.15.2/docs/installation.md#attributes). Leave out `data-ai-agent-deployment-id` to load the bundle without mounting, then call `HelpAI.init({ … })` from JavaScript ([Installation § Load without mounting](https://cdn.help.ai/sdk/js/elements/1.15.2/docs/installation.md#load-without-mounting)). --- ## Global `window.HelpAI` API After the IIFE boots: ```ts // Direct methods window.HelpAI.init(opts) window.HelpAI.mount(opts) window.HelpAI.destroy(widgetId?) window.HelpAI.getInstance(widgetId?) window.HelpAI.getAllInstances() window.HelpAI.hook(widgetId, event, fn) // Shortcuts for the default widget window.HelpAI.open() // or .open({ to: 'news', id? }) window.HelpAI.ask(text, { send }) // send defaults to false: fill the composer await window.HelpAI.ready() // the default widget, once connected (also when it mounts later) window.HelpAI.reset() // a new anonymous visitor (on logout) window.HelpAI.close() window.HelpAI.expand() window.HelpAI.on(event, fn) window.HelpAI.update(patch) // Metadata window.HelpAI.version // semver string window.HelpAI.brand // { id, displayName, cssPrefix, cdnUrl, ... } // Callable dispatch (for queued calls) window.HelpAI('init', opts) window.HelpAI('open') window.HelpAI('open', { to: 'help' }) window.HelpAI('ask', 'How do I book?') // or ('ask', 'How do I book?', { send: true }) window.HelpAI('ready', (chat) => chat.on('unread', showUnread)) // queue-able before the bundle loads window.HelpAI('reset') window.HelpAI('hook', '*', 'message', fn) ``` --- ## Pre-boot queues Used with the async loader snippet — write calls before the bundle arrives: ```html ``` The IIFE drains both on boot. --- ## Web component Custom element tag name: ``. The Web Component exposes the same curated parser fields without the script dataset's outer `data-` prefix: ```html ``` Changing a reactive attribute re-resolves the live widget. `iframe` and `start-minimized` are mount-time-only. The element has the instance's commands, state and events, so a page drives it directly: ```js const chat = document.querySelector('web-ai-chat'); chat.open(); // or chat.open({ to: 'help' }) chat.ask('How do I book a session?'); chat.close(); chat.expand(); chat.openForm('lead'); await chat.ready; chat.on('unread', (count) => console.log(count, chat.state)); chat.reset(); // on logout ``` `on()` subscriptions end when the element leaves the page. Importing `@helpai/elements/web-component` types `document.querySelector('web-ai-chat')` as `WebAiChatElement` in TypeScript; the [JSX typings](#jsx-typings) do the same for React, Vue and Svelte markup. ### No-script triggers Mark any element on the page and a click drives the default widget, with no script. A link keeps working without the widget; with it, the click opens the widget instead of following the link. | Attribute | Click does | | ------------------------------ | --------------------------------------------------------------------------------------------------------------------- | | `data-helpai-open` | Opens the panel; a value opens a tab (`home`, `chats`, `help`, `news`) or one article or post (`help/`) | | `data-helpai-ask=""` | Opens the chat with the text in the composer (empty value: the element's own text); add `data-helpai-send` to send it | | `data-helpai-form="
"` | Opens the chat on a form that has a `manual` trigger | | `data-helpai-close` | Closes the panel | ```html Help Billing questions ``` ### Deep links A page address can open the default widget on arrival — for support emails, docs and QR codes. Either parameter can sit in the query or the hash. The widget acts once and drops its parameters from the address (the page's own parameters and anchors stay), so a reload or a copied link doesn't repeat it. A linked question only fills the composer; it is never sent for the visitor. | Address | Opens | | -------------------------------------- | ---------------------------------------------------------- | | `?helpai=open` | The panel | | `?helpai=help/
` | Help at that article (any target `data-helpai-open` takes) | | `#helpai-ask=Can+I+export+invoices%3F` | The chat with the question in the composer | ### JSX typings Types-only subpaths register `` with its attributes in each framework's JSX, so attributes autocomplete, `ai-agent-deployment-id` is required, and a ref or `bind:this` is the typed element: ```ts import type {} from '@helpai/elements/jsx/react'; // React 19 import type {} from '@helpai/elements/jsx/svelte'; // Svelte 5, e.g. in src/app.d.ts import type {} from '@helpai/elements/jsx/vue'; // Vue 3 (also set compilerOptions.isCustomElement for the tag) ``` --- ## Brand constants `window.HelpAI.brand` (or `import { brand } from '@helpai/elements'`): ```ts interface Brand { id: string; // e.g. "help-ai" name: string; // NPM package name, e.g. "@helpai/elements" global: string; // window global, e.g. "HelpAI" cssPrefix: string; // "helpai" — used for all CSS classes + custom properties displayName: string; // "Help AI" — user-visible brand name cdnUrl: string; // "https://cdn.help.ai/sdk/js/elements/latest/elements.js" defaultBaseUrl: string; // "https://help.ai" — main origin; modules at /api/agent + /api/data defaultTheme: { accent: string; accentText: string; radius: string; fontFamily: string; }; } ``` Read-only. Useful for: - "Open in full Help AI dashboard" links. - "Download the mobile app" banners. - Dynamic theme fallbacks. --- ## Versioning Semver. `version` export matches the NPM package. - **Patch** (1.2.0 → 1.2.1): bug fixes, perf, docs. Auto-upgrade safe. - **Minor** (1.2.0 → 1.3.0): new options, new events, backward-compatible. Auto-upgrade safe. - **Major** (1.x.x → 2.0.0): breaking changes. Review the changelog. Pin a specific version with the CDN URL: ```html ``` Or use `/v1/` for the latest 1.x.x (auto-patches, no major breaks). --- ## See also - [Configuration](https://cdn.help.ai/sdk/js/elements/1.15.2/docs/configuration.md) — options with examples + context. - [Events & hooks](https://cdn.help.ai/sdk/js/elements/1.15.2/docs/events-and-hooks.md) — event handling deep-dive. - [Recipes](https://cdn.help.ai/sdk/js/elements/1.15.2/docs/recipes.md) — copy-paste scenarios. - [Troubleshooting](https://cdn.help.ai/sdk/js/elements/1.15.2/docs/troubleshooting.md) — when things go wrong. --- # Events & lifecycle hooks Listen for chat interactions, forward them to analytics, trigger custom UX. --- ## Two mechanisms | Mechanism | Scope | When to use | | ----------------------------------------------------- | ------------------------------------------ | ---------------------------------------------- | | **Instance events** — `chat.on(event, fn)` | One widget you hold a reference to | Custom UX wired to a specific widget | | **Global hooks** — `HelpAI.hook(widgetId, event, fn)` | Any widget on the page (by id or wildcard) | Analytics, error reporting, cross-widget sinks | Both deliver the same event stream. Both are safe to mix. --- ## Available events | Event | Fires on | Payload | | ----------------- | ----------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------ | | `open` | Panel opens (floating mode) | `void` | | `close` | Panel closes | `void` | | `message` | User sends a message, or a reply from the AI agent or a team member finishes | `{ role: "user" \| "assistant" \| "operator"; text: string }` | | `error` | Transport error or mid-stream error | `unknown` (error object or message) | | `expand` | Expanded tier toggled (⤢) | `boolean` (new state) | | `fullscreen` | Fullscreen tier toggled (⛶) | `boolean` (new state) | | `handshake` | Widget finishes booting (once per mount) | `HandshakeResponse` (dashboard config) | | `localeChange` | User picks a different language | `string` (new BCP-47 tag) | | `messageFeedback` | User rates a reply 👍/👎 (or clears it) | `{ messageId: string; value: "up" \| "down" \| null }` | | `hostEvent` | Your page calls `chat.track(name, data)` | `{ name: string; data?: Record }` | | `toolResult` | A [page tool](https://cdn.help.ai/sdk/js/elements/1.15.2/docs/page-tools.md) call settles, or the visitor answers a question card | `{ toolCallId: string; name?: string; status?: "completed" \| "failed" \| "declined" \| "stopped" \| "interrupted"; durationMs?: number }` | --- ## Instance events If you hold the `ChatWidgetInstance` returned from `init()` / `mount()`, subscribe with `.on()`: ```ts const chat = init({ publicKey: 'agent_pk_...', aiAgentDeploymentId: '01900000-0000-7000-8000-000000000000' }); const off = chat.on('message', (msg) => { console.log(`[${msg.role}] ${msg.text}`); }); // Later — unsubscribe off(); ``` `.on()` returns an unsubscribe function. Call it when you don't need the listener anymore (e.g., component unmount). ### Inline callbacks For one-off handlers, pass them directly to `init()`: ```ts init({ publicKey: 'agent_pk_...', aiAgentDeploymentId: '01900000-0000-7000-8000-000000000000', onMessage: (m) => analytics.track('chat_msg', m), onOpen: () => analytics.track('chat_open'), onClose: () => analytics.track('chat_close'), onError: (err) => Sentry.captureException(err), }); ``` Equivalent to calling `.on()` immediately after mount but cleaner for simple cases. ### Typed payloads Events are typed. In TypeScript, the payload is narrowed to the correct shape: ```ts import type { EventMap } from '@helpai/elements'; chat.on('message', (m) => { // m is typed as { role: string; text: string } console.log(m.role, m.text); }); chat.on('expand', (isExpanded) => { // isExpanded is typed as boolean if (isExpanded) console.log('expanded'); }); ``` --- ## Global lifecycle hooks Different problem: you want to listen to events from **any** widget on the page, possibly before the widget even loads. ```ts import { hook } from '@helpai/elements'; // Fire for every event on every widget hook('*', '*', (instance, event, data) => { analytics.track(`chat.${event}`, { widgetId: instance.widgetId, data, }); }); // Fire only for 'message' on the "support" widget hook('support', 'message', (instance, _event, data) => { logSupportMessage(data); }); ``` Wildcards (`"*"`) match any widget id or event name. Returns an unsubscribe function. ### Differences from `.on()` | | `chat.on(event, fn)` | `hook(widgetId, event, fn)` | | ------------------------- | -------------------- | ------------------------------------ | | Scope | One widget | Any matching widget(s) | | Callback signature | `(payload) => void` | `(instance, event, payload) => void` | | Needs the instance handle | Yes | No — works with zero-JS embeds | | Survives `destroy()` | No | Yes — registry is page-scoped | | Pre-boot queueable | No | Yes | ### Pre-boot hooks If you use the async loader snippet, you can register hooks **before the bundle arrives**. Push tuples onto `window.HelpAIHooks`: ```html ``` Once the bundle loads, `window.HelpAIHooks` stays as a "live facade" — you can keep pushing to it from lazily-loaded scripts and entries register eagerly. --- ## Event catalog — detailed ### `open` Fires when the floating panel opens. Does NOT fire for inline / standalone widgets (they're "always open"). ```ts chat.on('open', () => { document.body.classList.add('chat-open'); }); ``` ### `close` Fires when the panel closes. In inline + fullscreen, fires `fullscreen: false` instead (the Escape routes to "exit fullscreen"). ### `message` Fires **twice per user turn**: 1. On user send — `role: "user"`. Fires before the network round-trip. 2. On assistant finish — `role: "assistant"`. Fires when the final `finish` frame arrives (stream complete). ```ts chat.on('message', (m) => { if (m.role === 'user') { analytics.track('message_sent', { chars: m.text.length }); } else { analytics.track('message_received', { chars: m.text.length }); } }); ``` `text` is the **final assembled text** — not per-token. If you need token-by-token, subscribe to signals directly (advanced). ### `error` Fires on transport errors, mid-stream errors, or reducer errors. Payload is `unknown` — test its shape: ```ts chat.on('error', (err) => { const message = err instanceof Error ? err.message : String(err); console.error('Widget error:', message); Sentry.captureException(err); }); ``` The widget catches errors in listeners themselves — a throw inside your handler won't break the widget or stop other listeners. ### `expand` Fires when the user toggles the ⤢ expanded tier. Payload is the new state (`true` = entering expanded, `false` = leaving). ```ts chat.on('expand', (isExpanded) => { if (isExpanded) trackConversationSignal('expanded-tier'); }); ``` Does NOT fire when the server-pushed `initialSize` auto-applies on first mount. ### `fullscreen` Similar to `expand` but for the ⛶ fullscreen tier. Payload is the new state. ### `handshake` Fires **once per mount** — after the widget finishes booting (`/pai/handshake` resolves). Payload is the full bootstrap response from the backend: ```ts chat.on('handshake', (res) => { console.log('agent:', res.agent?.name); console.log('welcome messages:', res.welcome?.messages); console.log('server-pushed widget config:', res.config); }); ``` Does NOT fire on "new conversation" re-connects. Only the very first bootstrap. ### `conversationRebound` The server echoes the client-owned `visitorId`. If it replaces a client-minted `conversationId` during the handshake or first-message acknowledgement, the widget persists the replacement before firing this event. ```ts chat.on('conversationRebound', ({ previous, current }) => { analytics.track('conversation-rebound', { previous, current }); }); ``` The common handshake path echoes the requested conversation id and does not fire this event. ### `localeChange` Fires when the user picks a different language from the header picker. Payload is the new BCP-47 tag. ```ts chat.on('localeChange', (locale) => { // Sync the rest of the site's language document.documentElement.lang = locale; fetch('/api/user/preferences', { method: 'POST', body: JSON.stringify({ locale }), }); }); ``` --- ## Common patterns ### Send every event to Google Analytics 4 ```html ``` ### Forward to Segment ```ts import { hook } from '@helpai/elements'; hook('*', '*', (inst, event, data) => { window.analytics.track(`Chat ${event}`, { widgetId: inst.widgetId, ...(typeof data === 'object' && data !== null ? data : {}), }); }); ``` ### Capture errors in Sentry ```ts import { hook } from '@helpai/elements'; import * as Sentry from '@sentry/browser'; hook('*', 'error', (inst, _event, err) => { Sentry.captureException(err, { tags: { widget: inst.widgetId }, }); }); ``` ### Auto-close on inactivity ```ts const chat = init({ publicKey: 'agent_pk_...', aiAgentDeploymentId: '01900000-0000-7000-8000-000000000000' }); let timer; chat.on('open', () => { clearTimeout(timer); timer = setTimeout(() => chat.close(), 5 * 60 * 1000); // 5 min }); chat.on('message', () => { clearTimeout(timer); timer = setTimeout(() => chat.close(), 5 * 60 * 1000); }); chat.on('close', () => clearTimeout(timer)); ``` ### Notify an intranet app when a visitor starts chatting ```ts chat.on('message', (m) => { if (m.role === 'user') { fetch('/api/internal/chat-alerts', { method: 'POST', body: JSON.stringify({ firstMessage: m.text, userId: currentUser.id, at: Date.now(), }), }); } }); ``` ### Custom pulse on launcher when a new conversation is ready ```ts const chat = init({ publicKey: 'agent_pk_...', aiAgentDeploymentId: '01900000-0000-7000-8000-000000000000' }); chat.on('handshake', (res) => { if (res.welcome?.messages?.length > 0) { // Add a CSS class to your custom badge document.querySelector('#chat-nudge').classList.add('has-new'); } }); ``` ### React hook for message tracking ```tsx import { getInstance } from '@helpai/elements'; import { useEffect } from 'react'; export function useChatMessages(onMessage: (m: { role: string; text: string }) => void) { useEffect(() => { const chat = getInstance(); if (!chat) return; return chat.on('message', onMessage); }, [onMessage]); } ``` ### Instrument user's message lengths ```ts hook('*', 'message', (inst, _event, m) => { if (m.role !== 'user') return; const bucket = m.text.length < 20 ? 'short' : m.text.length < 100 ? 'medium' : 'long'; analytics.track('chat_message_length', { widgetId: inst.widgetId, bucket }); }); ``` --- ## Ordering guarantees For a single event fired on a widget: 1. Per-instance `.on()` listeners fire in registration order. 2. Global `hook()` listeners fire in registration order. 3. Errors in one listener don't break the others. Per-event fire order in a typical turn: ``` User types "hi", hits Enter │ ├─ message (role: user, text: "hi") ← fires immediately, before network │ │ [network round-trip, streaming begins] │ └─ message (role: assistant, text: "...") ← fires on stream finish ``` --- ## Unsubscribe hygiene Every subscription returns an `off()` function. Store and call them on cleanup to avoid memory leaks: ```ts const offs = [chat.on('open', onOpen), chat.on('message', onMessage), chat.on('error', onError)]; // Cleanup (e.g., React component unmount) for (const off of offs) off(); ``` React pattern: ```tsx useEffect(() => { const chat = init({ publicKey, aiAgentDeploymentId: '01900000-0000-7000-8000-000000000000' }); const offs = [chat.on('message', onMessage), chat.on('error', onError)]; return () => { for (const off of offs) off(); chat.destroy(); }; }, [publicKey]); ``` `chat.destroy()` also clears all instance subscriptions — so the explicit `off()` is belt-and-suspenders. Either way works. --- ## When listeners don't fire | Symptom | Likely cause | | ----------------------------------- | -------------------------------------------------------------------------------------------------------- | | `open` never fires | Widget is in inline/standalone mode — it's "always open" | | `handshake` never fires | Network error on `/pai/handshake`. `error` fires instead | | `message` fires twice per user send | Correct — user first, then assistant reply | | No events at all | `.on()` called before the instance was fully constructed. Use `init()`'s return value or a pre-boot hook | | Pre-boot hook never fires | Widget never mounted — check the public-key/deployment-id pair | [Troubleshooting](https://cdn.help.ai/sdk/js/elements/1.15.2/docs/troubleshooting.md) has the full diagnostic checklist. --- ## Next - [Analytics integration](https://cdn.help.ai/sdk/js/elements/1.15.2/docs/analytics.md) — GA4, Mixpanel, Segment, Amplitude, PostHog, Sentry, multi-provider - [Recipes](https://cdn.help.ai/sdk/js/elements/1.15.2/docs/recipes.md) — copy-paste scenarios - [API reference § events](https://cdn.help.ai/sdk/js/elements/1.15.2/docs/api-reference.md#events) — compact lookup - [Configuration § callbacks](https://cdn.help.ai/sdk/js/elements/1.15.2/docs/configuration.md#callbacks-shortcut-for-events) --- # Page tools Give the AI agent tools that act on your own page: open your booking form, read the visitor's cart, jump to a section, fill in a form. You describe each tool and write its function; the agent decides when to call it, the widget runs the function in the visitor's browser, and the agent continues the conversation with what it returned. Every page can offer different tools, so the agent can do on each page what that page is for. A tool has the [WebMCP](https://webmachinelearning.github.io/webmcp/) shape (the W3C draft for tools a web page offers to AI agents), so a tool you write for the widget also works with a browser's own WebMCP support, and the other way round. ```ts chat.registerTool({ name: 'open_booking', description: 'Open the booking form on this page for a meeting type. Use it whenever the visitor wants to book a call.', inputSchema: { type: 'object', properties: { meetingType: { type: 'string', enum: ['sales', 'support'] } }, required: ['meetingType'], }, execute({ meetingType }) { bookingForm.open(meetingType); return { opened: true }; }, }); ``` A visitor writes "I'd like to talk to sales"; the agent calls `open_booking` with `{ meetingType: 'sales' }`, your booking form opens, and the agent answers "The booking form is open, pick a time that suits you." ## Adding tools | Where you are | How | | ---------------- | ---------------------------------------------------------------------------------------------------------------------------- | | Script tag | `HelpAI('registerTool', tool)`. Works before the widget has loaded: the loader queues it. | | npm | `chat.registerTool(tool, { signal })`, or `init({ tools: [...] })`. | | React | `useHelpAITool(tool)` in the component that owns the feature, or ``. | | `` | `element.registerTool(tool)`, or the `tools` property (functions can't be attributes). | | Plain HTML | A form with `toolname` and `tooldescription` attributes ([Forms as tools](#forms-as-tools)); no script needed. | | A WebMCP browser | Tools registered with the browser's own `document.modelContext.registerTool()` are offered too ([WebMCP](#webmcp-browsers)). | `registerTool` adds the tool for every widget on the page. Pass an `AbortSignal` to remove it again, for example when a single-page app leaves the route that owns it: ```ts const route = new AbortController(); chat.registerTool(addToCart, { signal: route.signal }); // …when the visitor leaves the product page: route.abort(); ``` `useHelpAITool` does this for you: the tool is registered while the component is mounted, `execute` always runs the latest render's function, and React's StrictMode double mount still leaves one registration. ## A tool | Field | What it is | | ------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | `name` | Letters, digits, `_` and `-`, 58 characters at most; other characters become `_`. The agent sees `site__`. A name registered again replaces the earlier tool. | | `description` | When the agent should use it and what it does, 1024 characters at most. The agent picks tools by their descriptions: say when, not only what. Without one, `title` (then `name`) is used. | | `title` | Optional. A short human name. | | `inputSchema` | Optional. [JSON Schema](https://json-schema.org/) of the input, an object. The agent fills it in; use `enum` and `required` to keep the input exact. Without one, the tool takes no input. | | `annotations.consequentialHint` | Optional. `true` for a tool with real consequences (an order, a booking, a message to someone): the visitor approves each call in the chat first, and the function runs only on Approve. | | `execute` | `(input, { signal }) => result`. Return a value or a promise of one; throw to report a failure. `signal` aborts when the visitor moves on before the call finishes. | The `ModelContextTool` and `ToolAnnotations` types are exported for TypeScript; `ModelContextTool<{ sku: string }>` types `execute`'s input. WebMCP's other annotations (`readOnlyHint`, `untrustedContentHint`) are accepted and not used yet. Nothing about a tool can break the chat: a tool without a `name` or an `execute` function is left out with a warning in the browser console, and every message still goes through. ## Forms as tools A form becomes a tool with two attributes, in every browser: ```html ``` - Each named field is an input the agent can fill: a `select` or radio group takes one of its values, a checkbox `true` or `false`, a number field a number, anything else text. Hidden, disabled and file fields are left out, and so are passwords: the visitor types those, never the chat. - A field's description is its `toolparamdescription`, else its label, else its `aria-description`; `required` fields are required. - The agent fills the fields the way a visitor would (your framework's change handlers run). With `toolautosubmit` the form is then submitted; without it the visitor checks the form and presses its button. When a field still blocks the submit (a password, or a value the browser rejects), the form isn't submitted and the agent is told which field needs the visitor. ## WebMCP browsers In a browser with WebMCP (`document.modelContext`: in Chrome, turn on `chrome://flags/#enable-experimental-web-platform-features` or join the origin trial), the tools the page registered with the browser are offered to the agent too, and the widget runs them through the browser. A `
` is the exception: such a browser lists it too, but the widget fills it itself, as in every other browser, so your form's own submit handler keeps working and passwords stay the visitor's. Elsewhere, nothing changes. ## Which tools a message offers The widget reads the page's tools when the visitor sends each message: the browser's WebMCP tools, then forms, then the `tools` option, then `registerTool`. For a name in more than one place, the later one wins. When the agent calls a tool, the widget reads them again and runs the current function of that name, so a single-page app's route change applies straight away, with nothing to keep in sync. A call to a tool the page no longer has tells the agent the page doesn't offer it right now. So does a call to a tool the message didn't offer at all (the agent repeating an earlier call after a route change): the server answers it with "This page doesn't offer that action right now.", tells the agent not to call it again this turn, and the agent carries on from what it knows or asks the visitor. ## What the agent gets back - **A return value** becomes the tool's result, as JSON: dates become strings, `undefined` becomes `null`, functions are dropped, a page element is named (`"[dialog]"`), a repeated object reads `"[circular]"`, and a result over 30,000 characters is sent as `{ truncated: true, preview }`. Return what the agent needs to answer well, such as `{ opened: true }` or `{ items: 2, total: '$48' }`. - **A thrown error** (or a rejected promise) reaches the agent as the tool's error, with its message, so it can tell the visitor or try another way. For an expected miss ("that product is out of stock") a normal result such as `{ ok: false, reason: 'out of stock' }` gives the agent more to work with. With several calls in one step, the agent waits for all of them, and for any approvals, then continues once. ## Each call runs once - **Tabs.** Only the tab where the visitor sent the message runs the calls in its reply; another tab of the same conversation shows them running, then the agent's answer. - **Reloads and navigation.** A call that hadn't started when the page reloaded runs once after the reload. A call that was running is never run again: the agent is told the page reloaded, so it may or may not have taken effect, and checks with the visitor; its card reads "Not completed". A result that was ready but not yet sent is sent after the reload. The widget keeps this note per tab in `sessionStorage`, so it also covers a navigation to another page of your site. - **Leaving the page.** Results go out with `keepalive`, so they arrive even when the page navigates straight after. A tool that navigates should return first and leave after: ```ts execute({ path }) { setTimeout(() => location.assign(path), 0); // navigate after the result is sent return { navigating: path }; }, ``` - **The script loaded twice, or two widgets.** The call still runs once. - **No time limit.** The agent waits for the result. While a tool runs, the chat shows Stop. If the visitor presses it, or sends another message first, the call's `signal` aborts, its result is never sent, and its card reads "Not completed". A stopped call never runs again, not even after a reload. When `sessionStorage` is blocked (some private modes), the note lives in memory, so a reload can't tell a call that ran from one that didn't; everything else works the same. ## What the visitor sees A tool that runs on its own appears in the transcript only when the deployment shows tool calls (`behavior.showToolCalls`), with its input and result: "Running", then "Completed" or "Error" (the tool failed), or "Not completed" when the visitor stopped it or moved on, or a reload cut it short. A tool with `consequentialHint` always shows an approval card with the input; Reject tells the agent the visitor declined, and the function never runs. ## Events Each call this page settles fires `toolResult` with `{ toolCallId, name, status, durationMs }`: `status` is `completed`, `failed`, `declined` (the visitor rejected it), `stopped` (the visitor pressed Stop) or `interrupted` (a reload cut it short). It never carries the input or the result. ```ts chat.on('toolResult', ({ name, status, durationMs }) => analytics.track('help_ai_tool', { name, status, durationMs })); ``` ## Security - Tools run with your page's full privileges, as code you wrote. Anyone can also call your functions from the browser's developer tools, so a tool may do only what the visitor could do on the page themselves. - The agent chooses the input. Check it in `execute` as you would a form submission, and mark tools with real consequences (payments, deletions, messages to other people) `consequentialHint: true`, or confirm in your own UI. - The definitions are sent with each message and the results are stored in the conversation, like any tool's result, so never put secrets in a description or a result. ## Testing `testing/page-tools-manual.md` walks through every case above in a real browser on the `dev/page-tools.html` playground, with a real model or the mock backend. ## Troubleshooting - **The agent never uses the tool:** write the description as an instruction ("Use it whenever the visitor wants to book a call"), and check the console for a "skipped a tool" warning. - **The chat waits after a tool:** the function never settled (a promise that doesn't resolve). Return, or throw. - **"This page doesn't offer … right now":** the call came after the tool was removed (a route change aborted its signal), or the agent tried a tool this page doesn't register. Keep a tool registered while the agent may still use it. --- # Theming & customization Make the widget match your brand — colors, shapes, typography, and launcher style. --- ## TL;DR ```ts init({ publicKey: 'agent_pk_...', aiAgentDeploymentId: '01900000-0000-7000-8000-000000000000', theme: { accent: '#ff3e00', // your brand primary accentText: '#ffffff', // on-accent text radius: '12px', // corner rounding fontFamily: 'Inter, system-ui, sans-serif', }, launcher: { variant: 'pill', // circle / pill / bar / minimal label: 'Ask us', icon: 'spark', // chat / message / bubbles / spark / wand / bot / help / lifebuoy / headset / search / sparkles / concierge / wave }, composer: { sendButton: { shape: 'pill', variant: 'outline' }, }, }); ``` Rest of this page goes deep on each knob. --- ## Theme modes Four modes: - **`"auto"`** — follows the visitor's OS preference (`prefers-color-scheme`). - **`"light"`** — force light mode. - **`"dark"`** — force dark mode. - **`"host"`** — follows your page's own light/dark and flips with your site's toggle. The widget reads, in order, a `dark` / `light` class on `` or ``, a `data-theme`, `data-color-scheme`, `data-mode` or `data-bs-theme` attribute there, then the page's `color-scheme`, then the OS. ```ts init({ theme: 'auto' }); // default init({ theme: 'light' }); init({ theme: 'dark' }); init({ theme: 'host' }); // or , data-theme="host" on the script tag init({ theme: { mode: 'host', accent: '#635146' } }); // a mode beside token overrides ``` A page that sets only the mode keeps the deployment's token overrides from the dashboard (its brand accent) — so `` follows your site without losing the brand colours. ### Runtime switching End users can flip the theme through the `theme` row in the header's ⋯ menu. Their choice persists across conversations and syncs across devices. If you don't want a runtime picker, remove the action: ```ts init({ header: { actions: ['textSize', 'history', 'close', 'clear', 'expand', 'fullscreen', 'sound', 'language'], }, }); ``` --- ## Color overrides Theme properties you can override: | Option | CSS token | Purpose | Default (help.ai brand) | | ----------------------- | --------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------- | | `theme.accent` | `--helpai-accent` | Primary brand color | `#10B981` | | `theme.accentText` | `--helpai-accent-text` | Text shown on the accent color | `#FFFFFF` | | `theme.radius` | `--helpai-radius` | Corner rounding for buttons, cards, pills | `10px` | | `theme.fontFamily` | `--helpai-font` | Widget-wide font stack | `Inter, system-ui, -apple-system, Segoe UI, sans-serif` | | `theme.contentMaxWidth` | `--helpai-conversation-max` | Max width of the readable content column on a **wide** surface (fullscreen / page / standalone / wide inline) — content centres, bars stay full-bleed. `100%` disables the cap. Modal + drawer fill their card by default. | `768px` | ```ts init({ theme: { accent: '#ff3e00', accentText: '#fff', radius: '16px', fontFamily: '"Söhne", -apple-system, sans-serif', }, }); ``` All of these are optional — any you don't set inherits the brand default. ### Where the accent shows up Anywhere the widget draws "chrome" in your brand color: - The launcher FAB background. - Send / Stop button (when `composer.sendButton.variant: "filled"`). - Active language/theme menu checkmarks. - Link colors in rendered markdown. - Agent badge status dot. - Focus rings. ### Picking an accent Pick any brand color — the widget adapts it per theme so it stays readable, so a low-contrast raw color won't render an unreadable widget: - `accent`'s hue + chroma are honored; only its **lightness** is nudged into the visible band for the active theme (a near-black accent lightens on dark, a near-white one darkens on light). - **On-accent text** (the FAB / send glyph) is auto-derived to black or white, so `accentText` is **optional** — set it only to force a specific on-fill color (it's used as-is only on browsers without CSS relative-color support). - **Accent-colored text** (links, active tabs) uses a separate readable shade that clears **WCAG AA (4.5:1)** on the panel background for every hue. The one thing to verify yourself is the **FAB against your own page background** — it sits on your site, outside the widget's themed surface. Quick contrast check (optional): ```js // Paste in DevTools to check contrast function contrast(a, b) { // WCAG relative luminance formula const lum = (hex) => { const [r, g, b] = [1, 3, 5].map((i) => parseInt(hex.slice(i, i + 2), 16) / 255); const lin = (c) => (c < 0.03928 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4)); return 0.2126 * lin(r) + 0.7152 * lin(g) + 0.0722 * lin(b); }; const [la, lb] = [lum(a), lum(b)].sort((x, y) => y - x); return ((la + 0.05) / (lb + 0.05)).toFixed(2); } contrast('#ff3e00', '#ffffff'); // 3.79 — OK for large text, fail for small ``` --- ## Launcher The floating bubble + its nudges. `floating` mode only. ### Variants ```ts init({ launcher: { variant: 'circle' } }); // default — icon only, round init({ launcher: { variant: 'pill', label: 'Ask AI' } }); // rounded rectangle init({ launcher: { variant: 'bar', label: 'Need help?' } }); // wider rectangle init({ launcher: { variant: 'minimal', label: 'Chat' } }); // text-only ``` | Variant | Visual | Best for | | --------- | ---------------------- | ------------------------------ | | `circle` | 56px round bubble | Classic support chat | | `pill` | Icon + label rounded | "Ask AI" marketing CTA | | `bar` | Wider prominent bar | High-intent pages | | `minimal` | Transparent, text-only | Blends into custom site chrome | ### Sizes ```ts init({ launcher: { size: 'sm' } }); // 44px — compact init({ launcher: { size: 'md' } }); // 56px — default init({ launcher: { size: 'lg' } }); // 68px — prominent ``` ### Icons ```ts init({ launcher: { icon: 'chat' } }); // default — speech bubble init({ launcher: { icon: 'message' } }); // message square init({ launcher: { icon: 'bubbles' } }); // stacked chat bubbles init({ launcher: { icon: 'spark' } }); // sparkle / magic init({ launcher: { icon: 'wand' } }); // wand / magic init({ launcher: { icon: 'bot' } }); // robot face init({ launcher: { icon: 'help' } }); // question mark init({ launcher: { icon: 'lifebuoy' } }); // support ring init({ launcher: { icon: 'headset' } }); // support agent init({ launcher: { icon: 'search' } }); // friendly magnifier (search + smile) init({ launcher: { icon: 'sparkles' } }); // multi-point sparkles (AI / magic) init({ launcher: { icon: 'concierge' } }); // concierge bell (front-desk) init({ launcher: { icon: 'wave' } }); // waving hand (say hi) ``` Or pass raw SVG source for a fully custom icon: ```ts init({ launcher: { icon: ``, }, }); ``` SVG must be 24×24 viewbox with currentColor strokes/fills. ### Callout A single pre-engagement bubble next to the FAB. One component, configured for the use case — greeting, attention nudge, or onboarding tip. ```ts init({ launcher: { callout: { text: "Hey! I'm Aria. What can I help with?", shape: 'pill', // pill / bubble / callout position: 'auto', // auto / horizontal / vertical delayMs: 1200, autoDismissMs: 0, // 0 = never (dismiss on click). Or 5000 for auto-dismiss after 5s. animated: true, // subtle horizontal nudge animation persistent: true, // default — re-show on every visit (greeting behaviour) }, }, }); ``` #### Dismissal lifecycles `persistent` is the only behavioural difference between the two typical patterns: - **Recurring greeting (`persistent: true`, default)** — the bubble re-shows on every visit regardless of prior dismissal. Use for warm "Hi! 👋" copy that should always greet visitors. - **One-time hint (`persistent: false`)** — the exact text is persisted to localStorage. Once the user clicks the X (or opens the widget once), the same copy stays hidden across visits. Reconfiguring with NEW text bypasses the prior dismissal — a new message is a new ask. Pick `persistent: false` when the bubble is a CTA you don't want to nag with after the first dismissal. Tone: short, conversational. Think "Hi! 👋" or "Click here →" — not "CLICK HERE FOR DISCOUNTS". ### Hiding the launcher entirely Want a custom "open chat" button elsewhere on your site? ```ts const chat = init({ publicKey: 'agent_pk_...', aiAgentDeploymentId: '01900000-0000-7000-8000-000000000000', launcher: { hidden: true }, }); // Your button document.querySelector('#my-chat-btn').onclick = () => chat.open(); ``` --- ## Composer The input area. ### Send button ```ts init({ composer: { sendButton: { shape: 'pill', // circle / square / pill icon: 'arrowUp', // arrowUp / paperPlane / send / chevron variant: 'filled', // filled / outline / ghost }, }, }); ``` 3 × 4 × 3 = 36 combinations. Pick what matches your brand. Quick recipes: - **Minimal outline** — `{ shape: 'circle', variant: 'outline', icon: 'arrowUp' }` - **Bold filled** — `{ shape: 'pill', variant: 'filled', icon: 'paperPlane' }` - **Ghost text** — `{ shape: 'square', variant: 'ghost', icon: 'send' }` ### Disclaimer line Below the textarea, small grey text. Classic "AI can make mistakes" pattern. ```ts init({ footer: { composerDisclaimer: 'AI-generated. Verify important details.' } }); ``` Empty string = hidden. Keep it short (< 60 chars for mobile). ### Powered-by row Optional attribution below the disclaimer. ```ts init({ footer: { poweredBy: { text: 'Powered by Help AI', href: 'https://help.ai', logoUrl: 'https://cdn.help.ai/logo.svg', }, }, }); ``` - Omitted fields inherit the active brand's attribution defaults. Set `footer.poweredBy.text` to `""` (without a logo) to hide the row. - `href` wraps the row in a link with `target="_blank" rel="noopener noreferrer"`. --- ## Header actions See [Configuration § Header actions](https://cdn.help.ai/sdk/js/elements/1.15.2/docs/configuration.md#header-actions) for the full semantics. Quick customization examples: ### Minimal chrome Strip everything but close: ```ts init({ header: { actions: ['close'] }, }); ``` ### Language-enabled Enable the language row for multilingual marketing. Placement and ordering are fixed by the widget; the array is only an enabled set. ```ts init({ i18n: { availableLocales: ['en', 'fr', 'es', 'de'] }, header: { actions: ['language', 'theme', 'textSize', 'history', 'close', 'clear', 'expand', 'fullscreen', 'sound'] }, }); ``` ### History-focused chrome Keep the header focused on history and basic preferences: ```ts init({ header: { actions: ['history', 'close', 'clear', 'sound', 'language', 'theme'] }, features: { disableFileUpload: true, voice: 'disabled' }, }); ``` --- ## Internationalization ### Language switcher Enable the `language` action — when more than one locale is available, the header's ⋯ menu shows one segmented language row: ```ts init({ i18n: { availableLocales: ['en', 'fr', 'es', 'de', 'ja'], defaultLocale: 'auto', // sniff navigator.language }, header: { actions: ['language', 'theme', 'history', 'close'] }, }); ``` Locale display names come from `Intl.DisplayNames` in the active locale — so a French user sees "anglais, français, espagnol", not "English, French, Spanish". The user's pick persists across devices (via the backend). ### Strings overrides Every UI string is configurable. Two shapes: **Flat** (applies to active locale): ```ts init({ i18n: { strings: { composerPlaceholder: 'Ask me anything…', send: 'Go', newConversation: 'Start over', }, }, }); ``` **Nested** (per-locale): ```ts init({ i18n: { strings: { en: { send: 'Go', composerPlaceholder: 'Ask me anything…' }, fr: { send: 'Envoyer', composerPlaceholder: 'Demandez-moi…' }, es: { send: 'Enviar', composerPlaceholder: 'Pregúntame…' }, }, }, }); ``` ### Full list of string keys ``` attach, attachmentMimeRejected, attachmentTooLarge, attachmentTooMany, attachmentUploadFailed, conversationClosed, close, collapse, composerPlaceholder, dateLastWeek, dateOlder, dateToday, dateYesterday, dropZone, dropZoneRejected, errorGeneric, errorRetry, expand, exitFullscreen, fullscreen, history, historyBack, historyEmpty, historyLoading, historyTitle, language, launcherOpen, conversationLoading, loadMoreConversations, loadPreviousMessages, micStart, micStop, micUnsupported, moreActions, newConversation, panelTitle, resizeHandle, send, soundOff, soundOn, startNewConversation, stop, theme, themeAuto, themeDark, themeLight, thinking, usedTool ``` Any key you don't override keeps its default English value. --- ## Dashboard vs client-side **Every theming option above can also be set in your Help AI dashboard.** The dashboard lets non-engineers tune colors, copy, and launcher chrome without redeploying your site. Precedence: 1. Options you pass to `init()` win. 2. Dashboard settings fill in anything you leave unset. 3. Built-in defaults are the final fallback. This means you can: - Hardcode brand colors in code (so they never drift even if someone edits the dashboard). - Let the dashboard own launcher copy (so marketing can A/B-test without asking engineering). - Mix freely — every option is independently controlled. --- ## CSS-level customization (advanced) The widget renders inside a shadow root, so your page CSS can't reach it. If you need to go beyond the `theme` overrides, the widget exposes ~40 CSS custom properties on its host element you can tweak directly: ```css web-ai-chat, helpai-chat-host { /* Colors */ --helpai-bg: #fafafa; --helpai-fg: #111; --helpai-border: #e5e5e5; /* Set the accent via the `-user` slots — the widget DERIVES the theme-adapted `--helpai-accent` / `--helpai-accent-text` / `--helpai-accent-fg` from these, so overriding the derived tokens directly has no effect on modern browsers. */ --helpai-accent-user: #ff3e00; --helpai-accent-text-user: #fff; /* Panel dimensions */ --helpai-panel-w: 420px; --helpai-panel-h: 640px; --helpai-expanded-w: 640px; --helpai-expanded-h: 820px; /* Launcher */ --helpai-fab-size: 56px; /* Typography */ --helpai-font: "Inter", system-ui, sans-serif; --helpai-radius: 10px; /* Motion */ --helpai-dur-base: 240ms; --helpai-dur-quick: 120ms; } ``` These cascade through the shadow root — a single rule on the host element propagates to every component inside. Full list in `src/styles/tokens.css` in the source. ### Styling parts Five elements carry `part` names, so your own CSS restyles them through the shadow root with `::part()` — no reaching into the widget, and names that stay stable across releases: | Part | Element | | ---------- | -------------------------------------------------------------------------- | | `launcher` | The floating launcher button | | `panel` | The open panel (chat, tabbed messenger or empty state) | | `header` | The bar at the top of the panel (the chat, history and sub-screen headers) | | `messages` | The scrolling message list | | `composer` | The message box area | ```css web-ai-chat::part(launcher) { box-shadow: 0 8px 24px rgb(0 0 0 / 0.25); } web-ai-chat::part(panel) { border-radius: 20px; } web-ai-chat::part(header) { border-bottom-color: transparent; } ``` Use `helpai-chat-host::part(…)` for a widget mounted with `init()` / the script tag. Parts don't cross an iframe, so `presentation.iframe: true` widgets are styled with tokens only. Prefer the tokens above for colour and size; parts are for what tokens don't cover (shadows, corners, borders). For truly bespoke designs (e.g., replacing the launcher with your own component), use `launcher.hidden: true` + custom trigger, or a [no-script trigger](https://cdn.help.ai/sdk/js/elements/1.15.2/docs/api-reference.md#no-script-triggers). --- ## Per-deployment vs per-instance Some knobs are set at the **deployment** level (in your Help AI dashboard) and apply to every widget mounted for that deployment: - Theme defaults - Launcher chrome defaults - Welcome messages + suggestion chips - Agent persona (name, avatar, status) - Model routing - Tool allowlist - Action layout defaults - I18n strings Other knobs are best set **per-instance** in your `init()` call: - `widgetId` (for multi-widget pages) - `target` (for inline/standalone) - `user` identity - `onMessage` / `onOpen` / `onError` callbacks Any option set in `init()` wins over the deployment default, so you can always override. --- ## Next - [Events & hooks](https://cdn.help.ai/sdk/js/elements/1.15.2/docs/events-and-hooks.md) — listening to chat interactions, integrating with analytics. - [Recipes](https://cdn.help.ai/sdk/js/elements/1.15.2/docs/recipes.md) — copy-paste examples for common customization scenarios. - [Configuration reference](https://cdn.help.ai/sdk/js/elements/1.15.2/docs/configuration.md) — the full option table. --- # Recipes Copy-paste-ready solutions for common integration scenarios. --- ## Contents - [Custom launcher button](#custom-launcher-button) - [Programmatic control](#programmatic-control) - [Multiple widgets on one page](#multiple-widgets-on-one-page) - [SPA route changes](#spa-route-changes) - [Inline chat inside a sidebar](#inline-chat-inside-a-sidebar) - [Auto-open on a specific page](#auto-open-on-a-specific-page) - [Close on specific user actions](#close-on-specific-user-actions) - [Logged-in user personalization](#logged-in-user-personalization) - [Analytics forwarding](#analytics-forwarding) - [Error reporting](#error-reporting) - [Custom storage adapter](#custom-storage-adapter) - [Feature flags at runtime](#feature-flags-at-runtime) - [A/B test the launcher](#ab-test-the-launcher) - [Force a specific language](#force-a-specific-language) - [Gate the widget behind consent](#gate-the-widget-behind-consent) - [Conditionally show based on URL](#conditionally-show-based-on-url) - [Ask the assistant from your own buttons](#ask-the-assistant-from-your-own-buttons) - [Deep links in emails and docs](#deep-links-in-emails-and-docs) - [Follow your site's light/dark](#follow-your-sites-lightdark) --- ## Custom launcher button Hide the default FAB, trigger via your own UI: ```ts import { init } from '@helpai/elements'; const chat = init({ publicKey: 'agent_pk_...', aiAgentDeploymentId: '01900000-0000-7000-8000-000000000000', launcher: { hidden: true }, }); document.querySelector('#my-custom-chat-btn').addEventListener('click', () => { chat.open(); }); ``` No script at all — mark your own element and hide the default FAB in the dashboard or with `launcher-hidden`: ```html Chat with us Help articles ``` Badge your button with the widget's unread count (conversations with replies the visitor hasn't read): ```tsx // React function ChatButton() { const chat = useRef(null); const [unread, setUnread] = useState(0); useEffect(() => { const instance = init({ publicKey: import.meta.env.VITE_HELPAI_KEY, aiAgentDeploymentId: import.meta.env.VITE_HELPAI_DEPLOYMENT_ID, launcher: { hidden: true }, }); chat.current = instance; setUnread(instance.state.unread); const off = instance.on('unread', setUnread); return () => { off(); instance.destroy(); }; }, []); return ( ); } ``` --- ## Programmatic control ```ts const chat = init({ publicKey: 'agent_pk_...', aiAgentDeploymentId: '01900000-0000-7000-8000-000000000000' }); // Open / close chat.open(); chat.close(); // Toggle tiers chat.expand(); // normal ↔ expanded // Change theme at runtime chat.update({ theme: 'dark' }); // Change launcher label dynamically chat.update({ launcher: { variant: 'pill', label: 'New message!' } }); // Later — unmount chat.destroy(); ``` > **`update()` is a shallow merge.** Nested objects are replaced, not > merged. If you want to preserve other fields of a nested object, > spread it yourself first. --- ## Multiple widgets on one page Two (or more) chat widgets with different configs: ```ts const support = init({ widgetId: 'support', publicKey: SUPPORT_KEY, aiAgentDeploymentId: SUPPORT_DEPLOYMENT_ID, presentation: { position: 'bottom-right' }, launcher: { label: 'Support', variant: 'pill' }, }); const sales = init({ widgetId: 'sales', publicKey: SALES_KEY, aiAgentDeploymentId: SALES_DEPLOYMENT_ID, presentation: { position: 'bottom-left' }, launcher: { label: 'Sales', variant: 'pill', icon: 'spark' }, }); ``` Each has: - Its own chat thread + history. - Its own persisted state (`localStorage` namespaced by `widgetId`). - Its own event stream. Control individual widgets: ```ts support.open(); // opens support only sales.close(); // closes sales only // Look up by widgetId later import { getInstance, getAllInstances } from '@helpai/elements'; const supportAgain = getInstance('support'); const allWidgets = getAllInstances(); // both ``` --- ## SPA route changes Using `init()` is safe to call on every route change — it's idempotent. If an instance with the same `widgetId` exists, it merges options in rather than re-mounting. ```tsx // Next.js App Router 'use client'; import { init } from '@helpai/elements'; import { usePathname } from 'next/navigation'; import { useEffect } from 'react'; export function Chat() { const pathname = usePathname(); useEffect(() => { init({ publicKey: process.env.NEXT_PUBLIC_HELPAI_KEY!, aiAgentDeploymentId: process.env.NEXT_PUBLIC_HELPAI_DEPLOYMENT_ID!, // Per-page customization launcher: { hidden: pathname.startsWith('/checkout'), // hide on checkout }, }); }, [pathname]); return null; } ``` To fully tear down on navigation: ```tsx useEffect(() => { const chat = init({ publicKey, aiAgentDeploymentId: '01900000-0000-7000-8000-000000000000' }); return () => chat.destroy(); }, []); ``` --- ## Inline chat inside a sidebar ```html
``` The widget fills its parent container — 320px wide, full viewport height. Parent needs to have a defined size. ### In React / Vue / etc. ```tsx // React ``` ```vue ``` Parent CSS: ```css .chat-sidebar { width: 320px; height: 100vh; display: flex; flex-direction: column; } .chat-sidebar web-ai-chat { flex: 1 1 0; min-height: 0; /* critical — lets the widget shrink below its content */ } ``` --- ## Auto-open on a specific page ```ts const chat = init({ publicKey: 'agent_pk_...', aiAgentDeploymentId: '01900000-0000-7000-8000-000000000000', behavior: { startMinimized: true } }); if (window.location.pathname === '/help') { // Delay a beat so the widget has time to mount setTimeout(() => chat.open(), 500); } ``` Or forcibly start open on every page: ```ts init({ publicKey: 'agent_pk_...', aiAgentDeploymentId: '01900000-0000-7000-8000-000000000000', behavior: { startMinimized: false } }); ``` > User choice wins over `behavior.startMinimized`. If the user closes the > widget and reloads, it stays closed regardless. --- ## Close on specific user actions ```ts const chat = init({ publicKey: 'agent_pk_...', aiAgentDeploymentId: '01900000-0000-7000-8000-000000000000' }); // Close when user submits your signup form document.querySelector('#signup-form').addEventListener('submit', () => { chat.close(); }); // Close on ESC anywhere (the widget's internal ESC already does this // inside the panel; this adds page-wide) document.addEventListener('keydown', (e) => { if (e.key === 'Escape') chat.close(); }); ``` --- ## Logged-in user personalization Pass host-asserted context for personalized responses. This is informational for the AI — never an auth credential: ```ts // After the user signs in: import { init } from '@helpai/elements'; init({ publicKey: 'agent_pk_...', aiAgentDeploymentId: '01900000-0000-7000-8000-000000000000', userContext: { id: currentUser.id, email: currentUser.email, name: currentUser.name, plan: currentUser.plan, // arbitrary custom scalar field }, pageContext: { area: 'account', path: location.pathname, }, }); ``` In a SPA the visitor is usually anonymous on first load and logs in later. Rather than remounting, update context on the live instance — it re-handshakes with the **same** visitor/conversation, so the conversation is continuous: ```ts const chat = init({ publicKey: 'agent_pk_...', aiAgentDeploymentId: '01900000-0000-7000-8000-000000000000' }); // after login: chat.setUserContext({ id: currentUser.id, name: currentUser.name, plan: 'pro' }); // on SPA navigation: chat.setPageContext({ area: 'checkout', path: '/checkout' }); // on logout, on a computer someone else may use next: chat.reset(); // a new anonymous visitor: no history, no user context ``` `reset()` forgets the stored visitor, their current conversation, preferences and user context, then reconnects as a first visit; your `on()` listeners stay. Use `clearUserContext()` instead when the same person keeps the conversation. --- ## Analytics forwarding ### Google Analytics 4 ```html ``` ### Segment ```ts import { hook } from '@helpai/elements'; hook('*', '*', (inst, event, data) => { window.analytics.track(`Chat ${event}`, { widgetId: inst.widgetId, ...(typeof data === 'object' && data !== null ? data : {}), }); }); ``` ### Mixpanel ```ts import { hook } from '@helpai/elements'; hook('*', 'open', (inst) => mixpanel.track('Chat Opened', { widget: inst.widgetId })); hook('*', 'message', (inst, _e, m) => { if (m.role === 'user') mixpanel.track('Chat Message', { widget: inst.widgetId, chars: m.text.length }); }); ``` ### PostHog ```ts import { hook } from '@helpai/elements'; hook('*', '*', (inst, event, data) => { posthog.capture(`chat:${event}`, { widgetId: inst.widgetId, data }); }); ``` ### Amplitude ```ts import * as amplitude from '@amplitude/analytics-browser'; import { hook } from '@helpai/elements'; hook('*', '*', (inst, event, data) => { amplitude.track(`chat:${event}`, { widgetId: inst.widgetId, data }); }); ``` --- ## Error reporting ### Sentry ```ts import { hook } from '@helpai/elements'; import * as Sentry from '@sentry/browser'; hook('*', 'error', (inst, _event, err) => { Sentry.captureException(err, { tags: { widget: inst.widgetId }, }); }); ``` ### Datadog RUM ```ts import { datadogRum } from '@datadog/browser-rum'; import { hook } from '@helpai/elements'; hook('*', 'error', (inst, _event, err) => { datadogRum.addError(err, { widget: inst.widgetId }); }); ``` ### Rollbar ```ts import { hook } from '@helpai/elements'; import Rollbar from 'rollbar'; const rollbar = new Rollbar({ accessToken: '...', captureUncaught: true }); hook('*', 'error', (inst, _event, err) => { rollbar.error(err instanceof Error ? err : new Error(String(err)), { widget: inst.widgetId, }); }); ``` --- ## Custom storage adapter Default storage is `localStorage`. Custom adapters must be synchronous and implement `get`, `set`, and `remove`. ### Conversation storage (cleared on tab close) ```ts import { init, type ChatWidgetOptions } from '@helpai/elements'; type StorageAdapter = NonNullable; const conversationAdapter: StorageAdapter = { get: (key) => sessionStorage.getItem(key), set: (key, value) => sessionStorage.setItem(key, value), remove: (key) => sessionStorage.removeItem(key), }; init({ publicKey: 'agent_pk_...', aiAgentDeploymentId: '01900000-0000-7000-8000-000000000000', storage: conversationAdapter }); ``` ### Encrypted wrapper ```ts import { init, type ChatWidgetOptions } from '@helpai/elements'; type StorageAdapter = NonNullable; const encrypted: StorageAdapter = { get: (key) => { const raw = localStorage.getItem(key); return raw ? decrypt(raw) : null; }, set: (key, value) => localStorage.setItem(key, encrypt(value)), remove: (key) => localStorage.removeItem(key), }; init({ publicKey: 'agent_pk_...', aiAgentDeploymentId: '01900000-0000-7000-8000-000000000000', storage: encrypted }); ``` ### In-memory (no persistence) ```ts const mem = new Map(); const memoryStorage: StorageAdapter = { get: (k) => mem.get(k) ?? null, set: (k, v) => { mem.set(k, v); }, remove: (k) => { mem.delete(k); }, }; init({ publicKey: 'agent_pk_...', aiAgentDeploymentId: '01900000-0000-7000-8000-000000000000', storage: memoryStorage }); ``` --- ## Feature flags at runtime Disable features per-page or per-user: ```ts const chat = init({ publicKey: 'agent_pk_...', aiAgentDeploymentId: '01900000-0000-7000-8000-000000000000', features: { disableFileUpload: true, // hide paperclip + disable drop/paste voice: 'disabled', // hide mic button }, }); // Later — enable for paying users if (user.isPaying) { chat.update({ features: { disableFileUpload: false, voice: 'local', }, }); } ``` --- ## A/B test the launcher ```ts const variant = Math.random() < 0.5 ? 'A' : 'B'; const launcherByVariant = { A: { variant: 'circle', icon: 'chat' }, B: { variant: 'pill', label: 'Ask us', icon: 'spark' }, }; const chat = init({ publicKey: 'agent_pk_...', aiAgentDeploymentId: '01900000-0000-7000-8000-000000000000', launcher: launcherByVariant[variant], onOpen: () => analytics.track('chat_open', { variant }), onMessage: () => analytics.track('chat_message', { variant }), }); ``` --- ## Force a specific language ```ts init({ publicKey: 'agent_pk_...', aiAgentDeploymentId: '01900000-0000-7000-8000-000000000000', i18n: { locale: 'fr-CA', // force availableLocales: [], // hide the picker }, }); ``` Let users switch but start in French: ```ts init({ publicKey: 'agent_pk_...', aiAgentDeploymentId: '01900000-0000-7000-8000-000000000000', i18n: { defaultLocale: 'fr-CA', availableLocales: ['en', 'fr-CA', 'es'], }, }); ``` --- ## Gate the widget behind consent GDPR / CCPA pattern — only mount after the user accepts cookies: ```ts import { init, destroy } from '@helpai/elements'; let chat: ChatWidgetInstance | null = null; window.addEventListener('cookies-accepted', () => { chat = init({ publicKey: 'agent_pk_...', aiAgentDeploymentId: '01900000-0000-7000-8000-000000000000' }); }); window.addEventListener('cookies-rejected', () => { if (chat) { chat.destroy(); // Clear any data the widget wrote for (const key of Object.keys(localStorage)) { if (key.startsWith('helpai.')) localStorage.removeItem(key); } chat = null; } }); ``` With **in-memory storage** as a compromise when consent is declined: ```ts const mem = new Map(); init({ publicKey: 'agent_pk_...', aiAgentDeploymentId: '01900000-0000-7000-8000-000000000000', storage: { get: (k) => mem.get(k) ?? null, set: (k, v) => { mem.set(k, v); }, remove: (k) => { mem.delete(k); }, }, }); ``` Widget works but state evaporates on reload. --- ## Conditionally show based on URL ```ts import { init } from '@helpai/elements'; const allowedPaths = ['/docs', '/help', '/pricing']; if (allowedPaths.some((p) => location.pathname.startsWith(p))) { init({ publicKey: 'agent_pk_...', aiAgentDeploymentId: '01900000-0000-7000-8000-000000000000' }); } ``` SPA version — tear down when leaving allowed paths: ```ts import { init, destroy, getInstance } from '@helpai/elements'; function syncChat(pathname: string) { const allowed = ['/docs', '/help', '/pricing'].some((p) => pathname.startsWith(p)); if (allowed && !getInstance()) { init({ publicKey: 'agent_pk_...', aiAgentDeploymentId: '01900000-0000-7000-8000-000000000000' }); } else if (!allowed && getInstance()) { destroy(); } } // Hook into your router syncChat(location.pathname); // initial window.addEventListener('popstate', () => syncChat(location.pathname)); ``` --- ## Ask the assistant from your own buttons `ask(text)` opens the chat with the text in the composer for the visitor to send (`{ send: true }` sends it right away), so a pricing page, a product card or a help article can offer "Ask about this" in one line: ```ts const chat = init({ publicKey: 'agent_pk_...', aiAgentDeploymentId: '01900000-0000-7000-8000-000000000000' }); document.querySelector('#ask-pricing')?.addEventListener('click', () => chat.ask('How much does the Pro plan cost?')); ``` No script needed: mark the element with `data-helpai-ask`. An empty value asks the element's own text; add `data-helpai-send` to send it instead of filling the composer. ```html ``` The same call works everywhere the widget does: `HelpAI('ask', text, options)` with the loader snippet (queued until the bundle arrives) and `document.querySelector("web-ai-chat").ask(text, options)` for the element. The text waits until the chat can take it (still connecting, a pre-chat form to answer; a send also waits for a reply still arriving), so a button never needs to check the widget's state. Call it from something the visitor did. ## Deep links in emails and docs Link straight into the widget from a support email, a docs page or a QR code — the page opens with the widget where you pointed it: ```text https://acme.com/account?helpai=help/019a0b6c-7e2a-7000-9d3e-4a5b6c7d8e01 → Help, at that article https://acme.com/pricing#helpai-ask=Which+plan+fits+a+team+of+10%3F → the chat, with that question ready to send ``` The widget acts once and removes its parameters from the address. A linked question is never sent for the visitor. --- ## Follow your site's light/dark If your site has its own light/dark toggle, let the widget follow it instead of the OS: ```html ``` The widget reads your page's `dark` / `light` class, `data-theme` or `color-scheme` and flips with it, keeping the brand accent set in the dashboard. --- ## Next - [Events & hooks](https://cdn.help.ai/sdk/js/elements/1.15.2/docs/events-and-hooks.md) — full event API - [Configuration](https://cdn.help.ai/sdk/js/elements/1.15.2/docs/configuration.md) — every option - [Troubleshooting](https://cdn.help.ai/sdk/js/elements/1.15.2/docs/troubleshooting.md) — when things go wrong --- # Analytics integration Wire chat-widget activity into Google Analytics, Mixpanel, Segment, Amplitude, PostHog, Sentry, or any custom tracker. Multiple providers can run side-by-side — every action fires once on the widget's internal event bus, and you subscribe as many listeners as you want. > **No SDK plugin needed.** The widget exposes a typed pub/sub API. > Whatever your analytics provider's SDK looks like, you call it > inside a listener. --- ## TL;DR — one-line install ```html ``` Reload the page, click around the widget, watch GA4's DebugView. Every open / close / send / theme change / file attach shows up as its own event. --- ## Event catalogue Every interactive action emits one event with a typed payload. Subscribe via `chat.on(name, fn)` (instance) or `HelpAI.hook(widgetId, name, fn)` (global, pre-boot-safe). Full schema in [Events & hooks](https://cdn.help.ai/sdk/js/elements/1.15.2/docs/events-and-hooks.md). | Category | Event | Payload | | ---------------- | --------------------- | -------------------------------------------------- | | **Panel** | `open` | `void` | | | `close` | `void` | | | `expand` | `boolean` (true = expanded) | | | `fullscreen` | `boolean` (true = entering fullscreen) | | | `resize` | `{ width, height }` | | **Conversation** | `message` | `{ role, text }` (fires for user + assistant) | | | `send` | `{ text, attachmentCount }` (fires on user submit) | | | `stop` | `void` (user cancelled in-flight stream) | | | `clear` | `void` (new conversation) | | | `selectConversation` | `{ conversationId }` (resumed from history) | | | `suggestion` | `{ text }` (clicked a quick-reply chip) | | | `toggleHistory` | `{ view: "chat" \| "history" }` | | **Composer** | `attach` | `{ count, totalBytes }` | | | `removeAttachment` | `{ id }` | | | `voiceStart` | `void` | | | `voiceStop` | `{ durationMs }` | | | `voiceCancel` | `void` | | **Preferences** | `localeChange` | `string` (BCP-47 tag, e.g. `"fr"`) | | | `themeChange` | `"auto" \| "light" \| "dark"` | | | `soundToggle` | `{ muted }` | | | `sidebarToggle` | `{ collapsed }` (page mode) | | | `calloutDismiss` | `void` | | **Your page** | `hostEvent` | `{ name, data? }` (from `chat.track()`) | | **Lifecycle** | `error` | `unknown` (any unhandled error) | | | `handshake` | `HandshakeResponse` (full handshake payload) | | | `conversationRebound` | `{ previous, current }` | --- ## The two subscription APIs ### `chat.on(event, fn)` — instance-scoped Returns an `off` function. Use when you have a handle to the mounted instance (NPM / ESM / `mount()` returns it; `HelpAI.getInstance()` fetches the default). ```ts const off = chat.on('send', ({ text, attachmentCount }) => { console.log('user sent', text, 'with', attachmentCount, 'files'); }); // Later — unsubscribe off(); ``` ### `HelpAI.hook(widgetId, event, fn)` — global, pre-boot-safe Targets every widget that matches `(widgetId, event)`. Use when: - You want to register **before** the bundle finishes loading (queue replays once boot completes). - You have **multiple widgets** on one page and want a single listener for all of them (`widgetId: "*"`). - You want to track every event with one listener (`event: "*"`). ```html ``` The listener receives `(chat, event, payload)` — `chat` is the `ChatWidgetInstance` (so you can branch on `chat.widgetId`), `event` is the string event name, `payload` is the typed payload (or `undefined` for `void` events). --- ## Multiple analytics providers — three patterns ### Pattern A — one listener per provider (simplest) Each provider registers its own wildcard listener. Order doesn't matter; one provider erroring out doesn't break the others (the bus isolates listener exceptions). ```html ``` ### Pattern B — one wildcard listener, fan out internally When you want every event in every provider, declare a small `providers` array and let one listener push to all of them. New provider? Add a function to the array. ```js HelpAI(() => { const chat = HelpAI.getInstance(); // Each provider is `(event, payload) => void`. const providers = [ // GA4 (event, payload) => gtag('event', `chat_${event}`, flatten(payload)), // Mixpanel (event, payload) => mixpanel.track(`Chat ${titleCase(event)}`, flatten(payload)), // Segment / RudderStack (event, payload) => analytics.track(`Chat ${titleCase(event)}`, flatten(payload)), // Amplitude (event, payload) => amplitude.track(`chat_${event}`, flatten(payload)), // PostHog (event, payload) => posthog.capture(`chat_${event}`, flatten(payload)), // Server-side beacon (your own endpoint) (event, payload) => navigator.sendBeacon('/api/telemetry', JSON.stringify({ event, payload })), ]; chat.on('open', () => fanOut('open')); chat.on('close', () => fanOut('close')); chat.on('send', (p) => fanOut('send', p)); chat.on('message', (p) => fanOut('message', p)); chat.on('clear', () => fanOut('clear')); chat.on('selectConversation', (p) => fanOut('select_conversation', p)); chat.on('suggestion', (p) => fanOut('suggestion', p)); chat.on('themeChange', (m) => fanOut('theme_change', { mode: m })); chat.on('localeChange', (l) => fanOut('locale_change', { locale: l })); chat.on('attach', (p) => fanOut('attach', p)); chat.on('removeAttachment', (p) => fanOut('remove_attachment', p)); chat.on('voiceStart', () => fanOut('voice_start')); chat.on('voiceStop', (p) => fanOut('voice_stop', p)); chat.on('expand', (v) => fanOut('expand', { expanded: v })); chat.on('fullscreen', (v) => fanOut('fullscreen', { fullscreen: v })); chat.on('resize', (p) => fanOut('resize', p)); chat.on('toggleHistory', (p) => fanOut('toggle_history', p)); chat.on('soundToggle', (p) => fanOut('sound_toggle', p)); chat.on('sidebarToggle', (p) => fanOut('sidebar_toggle', p)); chat.on('calloutDismiss', () => fanOut('callout_dismiss')); chat.on('error', (e) => fanOut('error', { description: String(e) })); chat.on('handshake', (r) => fanOut('handshake', { aiAgentId: r.agent?.id })); function fanOut(event, payload) { for (const send of providers) { try { send(event, payload); } catch (e) { console.error('[analytics]', e); } } } }); function flatten(p) { return p == null ? {} : typeof p === 'object' ? p : { value: p }; } function titleCase(s) { return s.replace(/([A-Z])/g, ' $1').replace(/^./, (c) => c.toUpperCase()); } ``` ### Pattern C — single wildcard hook, multi-provider fan-out (most concise) The `'*' / '*'` wildcard hook receives every event from every widget on the page. Combined with a provider array, this is the same idea as Pattern B but in 5 lines. ```html ``` --- ## Multiple GA4 properties Same SDK, multiple `config` calls — gtag sends every event to every configured measurement id by default. No widget-specific change needed: ```html ``` Need to send some events to only one of them? Use `gtag`'s `send_to`: ```js chat.on('send', ({ text, attachmentCount }) => gtag('event', 'chat_send', { text_length: text.length, attachments: attachmentCount, send_to: ['G-PRIMARY', 'G-DEBUG-PROPERTY'], // skip secondary }), ); ``` --- ## Per-provider recipes ### Google Tag Manager (dataLayer) GTM consumes the `dataLayer` push — every push becomes a GTM event you can use as a trigger. ```js chat.on('send', ({ text, attachmentCount }) => { window.dataLayer.push({ event: 'chat_send', chat: { textLength: text.length, attachments: attachmentCount, widgetId: chat.widgetId }, }); }); ``` In GTM, create a custom-event trigger on `chat_send` and a variable chain reading `chat.textLength` etc. ### Segment ```js chat.on('send', ({ text, attachmentCount }) => analytics.track('Chat Send', { textLength: text.length, attachmentCount })); chat.on('clear', () => analytics.track('Chat Cleared')); chat.on('selectConversation', ({ conversationId }) => analytics.track('Chat Resumed', { conversationId })); chat.on('themeChange', (mode) => analytics.track('Chat Theme Changed', { mode })); chat.on('error', (err) => analytics.track('Chat Error', { description: String(err) })); ``` ### Amplitude ```js chat.on('send', ({ text, attachmentCount }) => amplitude.track('chat_send', { text_length: text.length, attachments: attachmentCount })); chat.on('themeChange', (mode) => amplitude.track('chat_theme', { mode })); chat.on('voiceStop', ({ durationMs }) => amplitude.track('chat_voice', { duration_ms: durationMs })); ``` ### PostHog ```js chat.on('send', ({ text, attachmentCount }) => posthog.capture('chat_send', { text_length: text.length, attachments: attachmentCount })); chat.on('handshake', (res) => posthog.capture('chat_connected', { agent: res.agent?.id })); chat.on('error', (err) => posthog.capture('chat_error', { description: String(err) })); ``` ### Sentry (error tracking) The `error` event fires for **every** caught error — the stream, async handlers, AND render crashes (the widget wraps its UI in an error boundary, so a render throw degrades to a quiet fallback instead of white-screening the host page; the error is reported here, never swallowed). It's also delivered to the per-instance `options.onError` callback. ```js chat.on('error', (err) => { Sentry.captureException(err, { tags: { source: 'chat-widget', widgetId: chat.widgetId }, }); }); ``` ### Forward logs to your observability stack (`onLog`) Beyond semantic events, the widget's scoped logger can stream **every log line** to a sink — pipe warnings/errors/debug into Sentry breadcrumbs, Datadog, etc. One global sink per page; it respects the active level (warnings + errors by default), so raise it with `setDebug("trace")` to capture everything. ```js // Pre-boot safe — wire it in the loader snippet before the bundle loads. HelpAI('onLog', ({ level, scope, message, payload }) => { if (level === 'error' || level === 'warn') { Sentry.addBreadcrumb({ category: `widget:${scope}`, level, message, data: payload }); } }); HelpAI('setDebug', 'debug'); // capture more than just warnings/errors // Or on a handle: chat.onLog(fn); pass null to detach. ``` ### Custom backend beacon `navigator.sendBeacon` survives page unload — useful for logging the last action before a visitor navigates away. ```js chat.on('close', () => { navigator.sendBeacon( '/api/telemetry', JSON.stringify({ event: 'chat_close', widgetId: chat.widgetId, ts: Date.now(), }), ); }); ``` --- ## Common patterns ### Your own events (`chat.track`) Report what the visitor does on your site, outside the chat, with `chat.track(name, data)`: ```ts chat.track('plan_viewed', { plan: 'pro' }); chat.track('cart_updated', { items: 2, total: 48.5 }); ``` Each call does two things: - **Analytics.** It sends one hit to help.ai's Elements analytics as a `hostEvent`, with your event name and data, like the widget's own events. A deployment with tracking off (or a browser sending Global Privacy Control or Do Not Track) sends nothing. - **The agent.** The widget keeps your last 10 events and sends them with the visitor's next message, as `recentEvents` in the page context (`{ name, data?, secondsAgo }`, oldest first), so an answer can take into account what the visitor just did: "I see you were looking at the Pro plan." Like the rest of the page context, the agent treats them as information from your site, never as instructions. They live only in this page view. The name is 1–64 characters. The data is flat: strings, numbers and booleans (other values are left out), up to 256 characters as JSON (longer data is not sent; the name still is). Nothing personal should go in either: use ids and plan names, not emails. `track()` also fires the `hostEvent` event, so your own listeners see it. Also `HelpAI('track', name, data)` with the script tag, `element.track(name, data)` on ``, and `useHelpAI().track` in React. ### Identify the visitor The widget mints a stable anonymous `visitorId` on first load and includes it in the `handshake` event's payload. Surface it to your analytics for cross-conversation attribution. ```js chat.on('handshake', (res) => { const visitorId = res.visitorId; // Segment analytics.identify(visitorId); // Mixpanel mixpanel.identify(visitorId); // PostHog posthog.identify(visitorId); // GA4 user_id gtag('config', 'G-XXXXXXX', { user_id: visitorId }); }); ``` ### Sample noisy events `message` fires twice per turn (user + assistant) and adds up fast. Drop the assistant half if you only care about user activity: ```js chat.on('message', ({ role, text }) => { if (role !== 'user') return; track('chat_message', { length: text.length }); }); ``` ### Sanitize PII before sending User-typed text may contain emails / IDs / etc. Hash or truncate before forwarding to third-party trackers: ```js chat.on('send', async ({ text }) => { const hash = await sha256(text); track('chat_send', { text_hash: hash, text_length: text.length }); // text itself never leaves the page. }); ``` ### Conditionally enable (consent-aware) ```js HelpAI(() => { if (!userConsentedToAnalytics()) return; const chat = HelpAI.getInstance(); chat.on('send', (payload) => gtag('event', 'chat_send', payload)); // … rest of subscriptions … }); ``` ### Disable on `localhost` / preview deploys ```js const isProd = location.hostname === 'help.ai'; if (isProd) { chat.on('send', (payload) => gtag('event', 'chat_send', payload)); } ``` ### Multi-widget pages — branch on widgetId ```js HelpAI('hook', '*', 'send', (chat, _evt, payload) => { if (chat.widgetId === 'support') { gtag('event', 'support_chat_send', payload); } else if (chat.widgetId === 'sales') { gtag('event', 'sales_chat_send', payload); } }); ``` --- ## TypeScript The event names + payloads are typed — your IDE autocompletes the shape of every callback argument: ```ts import type { ChatWidgetInstance, EventMap } from '@helpai/elements'; function wireAnalytics(chat: ChatWidgetInstance) { chat.on('send', (payload) => { // ^? { text: string; attachmentCount: number } gtag('event', 'chat_send', { text_length: payload.text.length }); }); chat.on('themeChange', (mode) => { // ^? "auto" | "light" | "dark" gtag('event', 'chat_theme', { mode }); }); } // Iterate every event for the fan-out pattern. const events: Array = ['open', 'close', 'send' /* … */]; ``` --- ## Troubleshooting ### "My listener never fires" - Are you registering **before mount completes**? Use the global `HelpAI(() => { ... })` queue form, or `HelpAI("hook", …)` (both are pre-boot-safe). - Wrong `widgetId`? Auto-boot uses `"default"`; pass `widgetId` to `init()` / `mount()` for named or multiple programmatic instances. Use `"*"` to match every widget. - Spelling — event names are case-sensitive (`themeChange`, not `theme_change`). ### "Listener fires twice" Every `chat.on(name, fn)` subscribes one listener. If you wired it inside a re-running effect (React `useEffect` without cleanup), you're registering N copies. Always call the returned `off` in your cleanup: ```jsx useEffect(() => { const off = chat.on("send", payload => analytics.track(...)); return off; // <-- critical }, [chat]); ``` ### "I want to see what's firing" Enable trace logging — every action fires a structured log line at `info` level: ```js chat.setDebug('info'); // or "trace" for SSE-frame-level detail ``` Or from the page URL: `?helpai-debug=info`. Persists in `localStorage`. ### "One provider throws and breaks the others" The event bus already isolates listener exceptions — one throw in listener A doesn't prevent listener B from firing. But within a single fan-out loop (Pattern B/C), wrap each call in try/catch yourself — see the snippets above. --- ## See also - [Events & hooks](https://cdn.help.ai/sdk/js/elements/1.15.2/docs/events-and-hooks.md) — full event reference, payload shapes, pre-boot queue semantics - [API reference](https://cdn.help.ai/sdk/js/elements/1.15.2/docs/api-reference.md) — `ChatWidgetInstance` shape - [Recipes](https://cdn.help.ai/sdk/js/elements/1.15.2/docs/recipes.md) — other end-to-end snippets - [Security & privacy](https://cdn.help.ai/sdk/js/elements/1.15.2/docs/security-privacy.md) — what data flows where --- # Security & privacy What the widget does with data. How it isolates itself from your page. How to make it work with strict CSPs. GDPR + compliance notes. --- ## TL;DR - **No cookies or fingerprinting.** The default adapter uses `localStorage`; a custom synchronous adapter can replace it. Anonymous allowlisted usage events use 1×1 pixel requests by default; `tracking.enabled: false`, GPC, or DNT disables them. - **CSP-compatible.** Small set of directives needed; shown below. - **Shadow DOM isolated.** Your site's CSS can't break the widget and vice versa. - **Optional iframe isolation** for strict threat models. - **GDPR-ready.** Your DPA with Help AI covers processing terms. - **SOC 2 Type II** certified (contact for report). - **No `eval`, no `innerHTML`, no dynamic code.** Source-available so you can audit. --- ## What data the widget collects On every mount + every message, the widget sends to the Help AI API: | Field | Required? | Example | | ---------------------- | --------- | ---------------------------------------------------------------------------- | | `visitorId` | Auto | `visitor_7a8f2b...` | | `conversationId` | Auto | `chat_abc123...` | | `userPrefs` | Auto | `{ locale: "fr", soundMuted: false, themeMode: "dark", textSize: "normal" }` | | `messages` (user text) | On send | `[{ role: "user", text: "Hello" }]` | | `attachments` (if any) | On send | Upload URLs + metadata | | `userContext` (if set) | Optional | `{ id, email, name, ...custom }` — informational only | | `pageContext` | Auto | `{ url, ...host area/path/title/custom }` — `url` auto-filled | | `locale` | Auto | `"en-US"` | When built-in usage tracking is enabled (the default), meaningful widget events also send an anonymous 1×1 pixel request to the configured data collector. Each request contains an allowlisted event name and scalar dimensions: anonymous visitor/conversation and deployment identifiers, page origin + path, referrer origin, screen/viewport values, language, timezone, presentation mode, theme, and event-specific counts or outcomes. Message text, form values, attachment contents, query strings, hashes, and host-provided user identity are not included. The host-controlled pathname is included and may itself contain identifiers; keep sensitive values out of paths or disable tracking. Set `tracking.enabled: false` to stop client-side hits; GPC and DNT override all configuration. See Anonymous usage tracking. On every receive (from the backend): - Streaming text responses. - Tool calls + results. - File references. The widget does **not** collect: - Cookies (it doesn't set any). - Explicit client IP or geolocation fields. The analytics collector may derive coarse location from the request IP and then discard the IP. - Keystroke telemetry. - Mouse movements. - Page content outside what the user types. --- ## Where data goes ``` ┌─────────────────┐ │ Visitor's │ HTTPS + TLS 1.3 │ browser │ ────────────────────────→ help.ai │ (widget) │ └─────────────────┘ │ ↓ ┌─────────────────────┐ │ Help AI backend │ │ ↓ │ │ Your deployment's │ │ configured model │ │ provider │ └─────────────────────┘ ``` - **Every deployment**: data flows to `help.ai` (the `/api/agent` and `/api/data` modules) and is processed in US-based infrastructure. - **Self-hosted**: enterprise customers can route via a bring-your-own API endpoint (`baseUrl` option). --- ## Persistence ### What the widget stores client-side by default The default adapter stores values in `localStorage` using three scopes. In the patterns below, `[.deploymentId]` is omitted when absent and `[.vN]` appears only after a handshake-provided storage version takes effect on the next load. | Scope | Key pattern | | ------------------- | --------------------------------------------------------------- | | Reset lever | `helpai.storageVersion` (stable and never versioned) | | Anonymous visitor | `helpai[.vN].visitorId` (shared by every widget and deployment) | | Widget + deployment | `helpai.[.deploymentId][.vN].` | Complete `ClientStorage` adapter-managed suffix inventory: | Suffix | Value | Lifetime / behavior | | ------------------------ | ------------------------------------------------------------------- | ---------------------------------------------- | | `conversationId` | Active UUIDv7 | Pointer removed by "New conversation" | | `draft.` | Exact unsent textarea text | Removed when accepted/emptied; no built-in TTL | | `userPrefs` | JSON: `locale`, `widgetSize`, `soundMuted`, `themeMode`, `textSize` | Cached locally; synchronized to the backend | | `panelOpen` | `"0"` / `"1"` | Per-device | | `panelSize` | `normal` / `expanded` / `fullscreen` | Per-device | | `calloutDismissed` | Exact dismissed callout text | Per-device | | `sidebarCollapsed` | `"0"` / `"1"` | Per-device, page mode | | `activeModule` | Messenger module id | Per-device | | `historyCount` | Last loaded conversation-row count | Per-device skeleton sizing | | `formsDone` | JSON map of form id to completion epoch milliseconds | Per-device; survives "New conversation" | `helpai.storageVersion` stores the next namespace version. A changed value abandons the prior versioned namespace on the following load; it does not migrate or delete old keys. The runtime logger is separate from `ClientStorage`: explicitly changing the debug level persists the brand-global `helpai.debug` key directly in `localStorage` so diagnostics survive reloads. The default warning level does not create that key. A custom storage adapter does not intercept it. Adapter-managed persistence does not use `Conversation`, `IndexedDB`, `Cache` storage, or a `ServiceWorker`. It sets no cookies. The default adapter stores unsent draft text as plaintext readable by other same-origin scripts. The widget does not include that text in Help AI network requests until the visitor sends it, and it applies no draft TTL. Customers with stricter requirements should pass an encrypted or in-memory adapter; an in-memory adapter intentionally gives up draft restoration after reload. ### Default-adapter scope | Data | Per-device | Cross-device | | ---------------------- | --------------------------- | ----------------------------------------------------------- | | `visitorId` | ✓ (minted once per browser) | | `conversationId` | ✓ | | | Unsent text draft | ✓ | | | Messages | | ✓ (server is authoritative; `loadConversation` re-hydrates) | | Locale preference | | ✓ (via `userPrefs`) | | Theme preference | | ✓ | | Sound mute state | | ✓ | | Text size preference | | ✓ | | Drag-resized size | | ✓ | | Panel open / closed | ✓ | | | Panel size tier | ✓ | | | Hint dismissed | ✓ | | | Sidebar state | ✓ | | | Active module | ✓ | | | History skeleton count | ✓ | | | One-time form markers | ✓ | | Cross-device sync uses the same `visitorId` — so if the user has the widget on multiple tabs of the same browser, they sync. Across different browsers / devices, the visitor is separate. `userContext.id` is informational only — it does not tie conversations together server-side. ### Custom storage You can swap `localStorage` for any synchronous adapter: ```ts const storage = { get: (key: string) => sessionStorage.getItem(key), set: (key: string, value: string) => sessionStorage.setItem(key, value), remove: (key: string) => sessionStorage.removeItem(key), }; init({ publicKey: '...', aiAgentDeploymentId: '01900000-0000-7000-8000-000000000000', storage, }); ``` Custom adapters receive the dynamic `draft.` keys and exact unsent text values in addition to identity and UI state, so adapter code owns their storage destination, retention, and privacy behavior. Use cases include encrypted `localStorage`, `sessionStorage`, and in-memory consent fallbacks. [Recipes § Custom storage](https://cdn.help.ai/sdk/js/elements/1.15.2/docs/recipes.md#custom-storage-adapter). --- ## Shadow DOM + iframe ### Shadow DOM (always on) Every widget renders inside an open shadow root attached to a custom element (`` or ``). Consequences: - **Your CSS can't reach in.** `body * { ... !important }` won't style the widget's internals. - **The widget's CSS can't leak out.** We don't pollute your ``. - **Events bubble through** (by W3C spec). Your `document.addEventListener('click', ...)` handlers still see clicks that happen inside the widget — useful for analytics, doesn't affect isolation. The widget sets `all: initial` on the shadow host element to neutralize inherited styles from hostile host pages. ### Iframe isolation (opt-in) For host pages with aggressive JS (monkey-patching `window.fetch`, replacing `console.log`, etc.): ```ts init({ publicKey: '...', aiAgentDeploymentId: '01900000-0000-7000-8000-000000000000', presentation: { iframe: true } }); ``` This wraps the widget in a same-origin iframe. The widget's JS runs in the iframe's realm — parent-page globals don't affect it. Composes with every `presentation.mode`: - `presentation: { mode: "floating", iframe: true }` → floating iframe in the corner. - `presentation: { mode: "inline", iframe: true }` → iframe fills your container. - `presentation: { mode: "standalone", iframe: true }` → fullscreen iframe. When to enable: - Host page monkey-patches `window.fetch` or `XMLHttpRequest`. - Host page attaches listeners that swallow widget events. - Compliance review requires "no access to parent page globals". - You embed the widget on pages you don't fully control. Not needed for 95% of deployments — shadow DOM alone is sufficient. ### In-widget content iframe sandbox (messenger modules) This is a **different** iframe from the runtime-isolation wrapper above. When the messenger renders remote help articles, status pages, or any `iframe`-action / `embed` block, it shows that remote URL inside a **heavily sandboxed iframe within the widget** (`src/ui/iframe-view.tsx`): - **Article / status drill-ins** (`IframeView`): ``` sandbox="allow-scripts allow-forms allow-popups allow-popups-to-escape-sandbox allow-downloads" referrerpolicy="no-referrer" ``` - **`embed` blocks** use a **narrower** sandbox — the same set **minus** `allow-downloads`. The critical omission in both is `allow-same-origin`. Without it, a sandboxed page is forced into an opaque origin and **cannot script the embedder, read its DOM, or touch its storage** — even if the remote URL happens to be same-origin with the host. `referrerpolicy="no-referrer"` also strips the referrer from those requests. **Article / status bodies are never fetched or inlined.** The widget only ever renders them as sandboxed _remote URLs_ — it does not download HTML and inject it. (Rich text that the backend controls travels as a content row's markdown `content` and is rendered DOM-safely, never via `innerHTML`.) A row opens as an iframe only when it has a `url`; otherwise its markdown renders in the native reader. See the content model. --- ## Content Security Policy The widget needs these directives to run under a strict CSP: ``` default-src 'self'; script-src 'self' https://cdn.help.ai; connect-src 'self' https://help.ai; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob: https:; font-src 'self' https:; media-src 'self' blob:; ``` ### Why each is needed - `script-src https://cdn.help.ai` — the widget JS is hosted on our CDN. If you self-host, use `'self'` or your CDN. - `connect-src https://help.ai` — the widget fetches from our API (`/api/agent` + `/api/data` on the main origin). - `style-src 'unsafe-inline'` — the widget's CSS is adopted via constructable stylesheets + an inline fallback. `'unsafe-inline'` is safe when scoped inside shadow DOM — host pages can't inject new styles there. - `img-src data: blob: https:` — inline SVG icons (`data:`), uploaded attachment previews (`blob:`), and avatar / attachment URLs. - `font-src https:` — if you use a custom `fontFamily` that loads web fonts. - `media-src blob:` — voice recording uses `MediaRecorder` + `blob:` URLs. ### Strict CSP + nonces If your CSP uses nonces, the widget's shadow DOM CSS doesn't need one (it's not injected into the main document). But the bootstrap ` ``` Adjust `script-src` to allow the nonce instead of `'unsafe-inline'`. ### CSP report-only testing Before enforcing: ``` Content-Security-Policy-Report-Only: default-src 'self'; script-src 'self' https://cdn.help.ai; ... ``` Reports land in your DevTools console + your `report-uri` endpoint without blocking anything. --- ## Subresource Integrity (SRI) Pin the bundle to a specific hash: ```html ``` We publish SRI hashes with each release in the [changelog](https://github.com/help-ai/elements/releases). Only the pinned version URL (`/v1.2.0/`) supports SRI — the floating URL (`/elements.js`) doesn't, by design. --- ## Authentication Browser access is scoped by `aiAgentDeploymentId`; a `publicKey` adds workspace authorization. Visitors are anonymous unless your server signs a [user token](#verified-users-usertoken) for your logged-in user. ### public key (default) ```ts init({ publicKey: 'agent_pk_...', aiAgentDeploymentId: '01900000-0000-7000-8000-000000000000' }); ``` Sent as the `x-public-key` header. Your public key authorizes access to your **public** widget configuration — it's designed to be safe to expose client-side. Think of it like a Stripe publishable key (`pk_live_...`). What your public key does NOT grant: - Access to other customers' data. - Write access to your deployment's config (that's gated by your dashboard conversation). - Server-side admin APIs. ### guest Omit `publicKey` but keep `aiAgentDeploymentId`; the widget connects to that public deployment as a guest without an auth header. ### Verified users (`userToken`) To show a logged-in user their own conversations on any device, and to let the AI rely on who they are, your server signs a short-lived JWT for them with the deployment's **secret key** (on the deployment's Install page; never put it in the browser): | Claim | Required | What it is | | ------- | -------- | ---------------------------------------------------------- | | `sub` | yes | Your own id for the user (a string, up to 255 characters) | | `exp` | yes | Expiry, in seconds since the epoch; keep it short (1 hour) | | `name` | no | Their name, given to the AI as a verified fact | | `email` | no | Their email, given to the AI as a verified fact | Sign it with HS256: ```js // Node (jose) import { SignJWT } from 'jose'; const secret = new TextEncoder().encode(process.env.HELPAI_SECRET_KEY); const userToken = await new SignJWT({ name: user.name, email: user.email }).setProtectedHeader({ alg: 'HS256' }).setSubject(String(user.id)).setExpirationTime('1h').sign(secret); ``` ```python # Python (PyJWT) import os, time, jwt user_token = jwt.encode({"sub": str(user.id), "exp": int(time.time()) + 3600, "name": user.name, "email": user.email}, os.environ["HELPAI_SECRET_KEY"], algorithm="HS256") ``` ```php // PHP (firebase/php-jwt) use Firebase\JWT\JWT; $userToken = JWT::encode(['sub' => (string) $user->id, 'exp' => time() + 3600, 'name' => $user->name, 'email' => $user->email], getenv('HELPAI_SECRET_KEY'), 'HS256'); ``` Serve it from an endpoint only your signed-in user can reach, and hand the widget a function that fetches it: ```ts const userToken = () => fetch('/api/help-ai-token').then((res) => res.text()); init({ publicKey: 'agent_pk_...', aiAgentDeploymentId: '01900000-0000-7000-8000-000000000000', userToken }); // signed in at load chat.login(userToken); // or after your sign-in (`HelpAI('login', userToken)`, ``) chat.reset(); // on sign-out (`HelpAI('reset')`) ``` - The widget sends the token only to the agent API, as the `x-user-token` header. The function is called when a token is first needed and again when the server answers `401 USER_TOKEN_INVALID` (expired or rejected); the request is then sent once more. A fixed string can't be refreshed, so an expired one fails until you call `login()` again. - A token the server can't verify is refused; it never falls back to an anonymous visitor. - Signing in moves the visitor's conversations on that browser to the user. From then on they reach them on any device where they're signed in, and nobody else does, the signed-out browser included. - Signing out (`reset()`, or dropping the token) or switching to another user starts over as a new visitor: nothing of the previous user stays in the browser. - Keep the function's identity stable for one user (a module function, or `useCallback` keyed on your user id): a different function counts as a different user. ### user / page context is NOT auth ```ts init({ publicKey: 'agent_pk_...', aiAgentDeploymentId: '01900000-0000-7000-8000-000000000000', userContext: { id: '42', email: 'jane@help.ai', name: 'Jane' }, pageContext: { area: 'support' }, }); ``` `userContext` and `pageContext` are **untrusted, host-asserted information for the AI** — never an authentication credential. The backend does not register or authenticate these end users. The `id` field is a string you provide, not a cryptographic identity. Both objects are sanitized client-side before they leave the page (reserved auth-like keys such as `jwt` / `token` / `authorization` / `password` are stripped). Use them for personalization and context only. #### Trust zones ```mermaid flowchart TB subgraph Host["Host site — example.com (untrusted by backend)"] direction TB H1["Asserts userContext / pageContext
(who/where the visitor is)"] H2["Has its OWN auth — some sites log users in,
some are guest-only"] end subgraph Widget["Widget — runs in the visitor's browser"] direction TB W1["Mints + owns anonymous visitorId / conversationId"] W2["Sanitizes context client-side
(strips jwt/token/password/…)"] W3["Encodes the context field
base64url({ user, page }) on every request"] W4["Authenticates to backend via
x-public-key + x-ai-agent-deployment-id"] end subgraph Backend["Help AI backend"] direction TB B1["Authenticates the HOST's agent deployment
(NOT the end user)"] B2["Treats userContext / pageContext as
informational + untrusted"] B3["Never registers end users; verifies a signed userToken when the host sends one"] end Host -->|"context (untrusted)"| Widget Widget -->|"HTTPS/TLS + headers + context field"| Backend note["publicKey + aiAgentDeploymentId auth the host's agent; only a userToken signed by the host's server identifies the end user."] Backend -.-> note ``` #### The `context` field — base64url, obfuscation not encryption `userContext` + `pageContext` ride **every** request as one `context` field = `base64url(JSON.stringify({ user, page }))` (JSON body for POSTs, form field for multipart POSTs, `?context=` for GETs) — alongside the required `visitorId` / `conversationId` identity. base64url keeps PII (email / phone) out of plaintext server access logs, the devtools network tab, and analytics proxies — but it is **obfuscation, not encryption**: TLS already protects the bytes in transit, and anyone can base64url-decode it. Never put a secret in context. ### Key rotation The dashboard has no key rotation today. - **public key** — public by design: it only names the deployment. Limit where it works with the deployment's **Allowed websites**. - **secret key** — keep it on your server. If it leaks, create a new deployment (it gets new keys), move your embed and your token signing to it, and delete the old one. User tokens signed with the old key stop verifying, so every verified user is signed out until your server signs new ones. --- ## GDPR + regional compliance ### Data processing agreement (DPA) Help AI is the data processor; you are the data controller. Our DPA is available on request and covers: - Processing purposes. - Sub-processor list. - Data transfer mechanisms (SCCs). - Deletion / return on contract termination. ### Consent management The widget doesn't set cookies, but depending on your jurisdiction + how you use `userContext`, you may need consent. Common patterns: #### Pattern A — only mount after consent ```ts window.addEventListener('consent-accepted', () => { init({ publicKey: '...', aiAgentDeploymentId: '01900000-0000-7000-8000-000000000000' }); }); ``` No chat available until consent. Simple, strict. #### Pattern B — memory-only without consent ```ts const memoryAdapter = /* see recipes */; init({ publicKey: '...', aiAgentDeploymentId: "01900000-0000-7000-8000-000000000000", storage: consentGiven ? undefined : memoryAdapter, }); ``` Widget works without consent but state evaporates on tab close. #### Pattern C — annotate visitors as consented ```ts init({ publicKey: '...', aiAgentDeploymentId: '01900000-0000-7000-8000-000000000000', userContext: consentGiven ? { id: userId } : undefined, }); ``` Anonymous (no user context) when no consent; context attached with consent. ### Data subject requests - **Access**: visitors can view their chat thread in the widget itself. For server-side exports, contact [support@help.ai](mailto:support@help.ai) with the `visitorId` or `userContext.id`. - **Deletion**: clicking "New conversation" orphans the old thread from the client. For permanent deletion: contact support. - **Portability**: export via server-side API. See dashboard → API docs. ### Region Every deployment uses one data plane: `help.ai`, in the US (AWS us-east-1, us-west-2). --- ## Security audits - **SOC 2 Type II** — annual. Report available on request (NDA required). - **Pen testing** — quarterly external pen tests. - **Dependency audit** — `bun audit` on every CI run. No high-severity vulns allowed in releases. - **Bug bounty** — [help.ai/security](https://help.ai/security) — up to $10,000 for critical reports. --- ## What the widget can / can't do on your page **Can do:** - Render UI in its own shadow root. - Listen to its own keyboard + pointer events. - Store data under `helpai.*` localStorage keys. - Fetch from the configured `baseUrl`. - Record audio (with user permission) for voice input. - Upload files the user attaches. **Can't do:** - Read or set cookies. - Modify your page's DOM outside its own shadow root. - Read your localStorage keys that don't start with `helpai.`. - Intercept your form submissions. - Mine cryptocurrency / run unrelated scripts. - Navigate your page. - Access `iframe` content across origins. The source is available for audit — `dist/help-ai/elements.js` is the production bundle; `src/*` is the un-minified source. --- ## Supply chain - **Bundled runtime dependencies** are pinned in `package.json` and `bun.lock`. - **No third-party analytics** in the widget itself. Anything you wire via hooks is your choice. - **No CDN tracking**. We don't use CloudFlare Workers, Google Tag Manager, or any third-party on our CDN path. Direct from our infrastructure. --- ## Outage + incident response - **Status page**: [help.ai/status](https://help.ai/status). - **Incident notifications**: subscribe via the status page. - **Degraded mode**: if the API is down, the widget still renders (the bundle is cached). Attempting to send shows an error. Cached chats remain viewable. --- ## Reporting a security issue Please don't file public issues for vulnerabilities. Email [security@help.ai](mailto:security@help.ai) with: - The affected version. - Steps to reproduce. - The potential impact. - PGP key: `0xCAFEBABE...` (download from [help.ai/.well-known/security.asc](https://help.ai/.well-known/security.asc)). We respond within 24 hours and coordinate disclosure on your schedule. --- # Browser support ## Supported browsers Baseline support matches modern evergreen browsers: | Browser | Minimum version | Notes | | ---------------- | --------------- | -------------------------- | | Chrome / Edge | 100 | Released April 2022 | | Firefox | 100 | Released May 2022 | | Safari | 15 | macOS Big Sur + / iOS 15 + | | Opera | 86 | Chromium-based | | Samsung Internet | 17 | Android | Chromium-based browsers (Brave, Vivaldi, Arc, DuckDuckGo Browser) work if their underlying Chromium version is ≥ 100. ## Explicitly not supported - **Internet Explorer** (any version). - **Legacy Edge** (pre-Chromium, EdgeHTML). - **Opera Mini** (proxy-rendered, no JS). - **UC Browser** (ancient WebKit fork). If you need to support IE or similar, the widget won't render. Detect and show a fallback contact form: ```js const supportsWidget = 'attachShadow' in Element.prototype && 'fetch' in window && typeof ReadableStream === 'function'; if (!supportsWidget) { document.getElementById('legacy-contact').style.display = 'block'; } ``` --- ## Features used The widget relies on: | API | Feature detection | Why | | ------------------------------ | ---------------------------------------------- | ----------------------------------------------------------------- | | Shadow DOM (`attachShadow`) | `'attachShadow' in Element.prototype` | Widget rendering isolation | | Custom Elements | `typeof customElements` | `` element | | Constructable Stylesheets | `'adoptedStyleSheets' in ShadowRoot.prototype` | Efficient CSS adoption (falls back to `