Skip to content

Installation

Every install path in depth: script tag, async loader, npm, web component, CDN URLs and version pinning.

Last updated

Deep-dive for every install path. See Quickstart for the 5-minute version.


Choose your path#

PathBest forDelivery
A. <script data-*> auto-bootStatic HTML, WordPress, no build stepCDN
B. Async loader snippetTag managers, analytics pipelinesCDN
C. NPM packageReact, Next.js, Vue, Svelte, SolidJS, any bundlerNPM
React kitReact, Next.js, Remix: <HelpAI /> + useHelpAI()NPM
D. Web ComponentDeclarative tags in any frameworkCDN or NPM

CDN URL#

The bundle is served from a versioned path — the bare root 404s:

https://cdn.help.ai/sdk/js/elements/<version>/<file>
  • …/elements/<version>/elements.js — pinned to an exact release (immutable, recommended for production).
  • …/elements/latest/elements.js — always the newest release (a 302 to its pinned path, cached up to 4 hours).

Files: elements.js (auto-boot + queue + tag), elements-web-component.esm.js (standalone web-component ESM). The examples below use latest; pin a version in production so a release never changes behaviour under you.

All four paths:

You can combine them — e.g., a Next.js page can use path C, and a marketing landing can use path A, both pointing at the same deployment.


Path A — <script data-*> auto-boot#

Fastest path. Zero custom JavaScript.

html
<!-- In your HTML <head> -->
<script src="https://cdn.help.ai/sdk/js/elements/latest/elements.js" data-public-key="agent_pk_abc123..." data-ai-agent-deployment-id="01900000-0000-7000-8000-000000000000" defer></script>

Attributes#

The zero-JavaScript path intentionally exposes a small, flat subset of ChatWidgetOptions. These are the attributes parsed by the current bundle:

AttributeMaps toExample
data-public-keypublicKeyagent_pk_...
data-ai-agent-deployment-idaiAgentDeploymentIddeployment UUID
data-base-urlbaseUrlhttps://help.ai
data-agent-api-base-urlagentApiBaseUrlhttp://localhost:3069
data-api-base-urldataApiBaseUrlhttp://localhost:3071
data-themethemeauto / light / dark
data-mode / data-positionpresentationfloating / bottom-right
data-iframepresentation.iframetrue
data-width / data-heightpresentation.size420px / 640px
data-response-modebehavior.responseModestreaming / buffered
data-start-minimizedbehavior.startMinimizedfalse
data-show-reasoningbehavior.showReasoningtrue
data-show-tool-callsbehavior.showToolCallstrue
data-show-sourcesbehavior.showSourcestrue
data-disable-message-feedbackbehavior.disableMessageFeedbacktrue
data-auto-send-suggestionsbehavior.autoSendSuggestionstrue
data-pop-out-urlbehavior.popOutUrlhttps://chat.example.com
data-localei18n.localefr-FR
data-launcher-variant / -size / -iconlauncher fieldspill / lg / spark
data-launcher-label / -hiddenlauncher fieldsAsk AI / true
data-callout-text / -delaylauncher.calloutHi! 👋 / 2000
data-actionsheader.actions (CSV)theme,history,close
data-features-disable-file-uploadfeatures.disableFileUploadtrue
data-features-voicefeatures.voicelocal / server / disabled
data-endpoint-upload / -transcribeendpoints/pai/upload
data-user-id / -email / -nameuserContext scalar fieldsuser_42
data-user-contextuserContext extras (JSON)'{"plan":"pro"}'
data-page-area / -url / -path / -titlepageContext scalar fieldssupport
data-page-contextpageContext extras (JSON)'{"experiment":"b"}'

Unknown attributes are ignored. Full option semantics: Configuration.

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(...). 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
<script src="https://cdn.help.ai/sdk/js/elements/latest/elements.js"></script>
<script>
  // Mount later, conditionally
  document.addEventListener('DOMContentLoaded', () => {
    if (shouldShowChat()) {
      HelpAI.init({ publicKey: 'agent_pk_...', aiAgentDeploymentId: '01900000-0000-7000-8000-000000000000' });
    }
  });
</script>

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
<script>
  (function (w, d) {
    w.HelpAI =
      w.HelpAI ||
      function () {
        (w.HelpAI.q = w.HelpAI.q || []).push(arguments);
      };
    w.HelpAIHooks = w.HelpAIHooks || [];
    var s = d.createElement('script');
    s.async = 1;
    s.src = 'https://cdn.help.ai/sdk/js/elements/latest/elements.js';
    d.head.appendChild(s);
  })(window, document);

  // Queue commands — they run in order once the bundle arrives.
  HelpAI('init', { publicKey: 'agent_pk_abc123...', aiAgentDeploymentId: '01900000-0000-7000-8000-000000000000' });
</script>

Queueable commands#

CallWhat 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
<script>
  window.HelpAIHooks = window.HelpAIHooks || [];

  // Fire for every event on every widget
  window.HelpAIHooks.push([
    '*',
    '*',
    (inst, event, data) => {
      console.log('chat event:', event, 'from', inst.widgetId, data);
    },
  ]);

  // Or target one widget + one event
  window.HelpAIHooks.push([
    'support',
    'message',
    (inst, _event, msg) => {
      analytics.track('chat_message', msg);
    },
  ]);
</script>

Full details: Events & 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.

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 <web-ai-chat> 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<ChatWidgetInstance | null>(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
<!-- Load the bundle once (anywhere in the page) -->
<script src="https://cdn.help.ai/sdk/js/elements/latest/elements.js" defer></script>

<!-- Then use the element anywhere, any number of times -->
<web-ai-chat public-key="agent_pk_..." ai-agent-deployment-id="..." mode="inline" theme="auto" style="display: block; height: 480px"></web-ai-chat>

Attributes#

Same parser fields as <script data-*>, but without HTML's outer data- dataset prefix. Values are strings (booleans are "true" / "false"). data-api-base-url keeps its name because data refers to the data API.

Reactive attributes re-resolve the live widget. iframe and start-minimized are mount-time-only.

js
document.querySelector('web-ai-chat').setAttribute('theme', 'dark');
// The widget flips to dark mode.

The element also has the panel methods: open(), close(), expand(), openForm(id) and ask(text, options), which opens the chat with text in the composer for the visitor to send ({ send: true } sends it):

js
document.querySelector('web-ai-chat').ask('How do I book a session?');
document.querySelector('web-ai-chat').ask('How do I book a session?', { send: true });

Default mode#

A bare <web-ai-chat> tag initially resolves to mode="floating"; the handshake's config.presentation.mode supplies the deployment mode when the host did not set one.

Container sizing (inline, page, standalone)#

These modes have the host element declare width: 100%; height: 100% inside its shadow root. Width fills the parent automatically; height only resolves if the parent has a defined height. An undefined parent height collapses the widget to 0px tall and it appears empty.

html
<!-- ✓ Viewport-filling page -->
<html style="height: 100%">
  <body style="height: 100%; margin: 0">
    <web-ai-chat mode="page" public-key="…" ai-agent-deployment-id="…"></web-ai-chat>
  </body>
</html>

<!-- ✓ Sized container (any of px / vh / 100% chain) -->
<div style="height: 600px">
  <web-ai-chat mode="page" public-key="…" ai-agent-deployment-id="…"></web-ai-chat>
</div>

<!-- ✓ Flex / grid child with explicit sizing -->
<body style="display: flex; flex-direction: column; height: 100vh; margin: 0">
  <header>…</header>
  <web-ai-chat mode="page" style="flex: 1 1 0; min-height: 0" …></web-ai-chat>
</body>

<!-- ✗ Body has no defined height → host's 100% resolves to 0 -->
<body>
  <web-ai-chat mode="page" …></web-ai-chat>
</body>

floating mode is unaffected — the host is position: fixed and the panel's size is set by panelSize (normal / expanded / fullscreen), independent of the parent. modal and drawer are also self-sized: modal anchors to the viewport center; drawer docks to the side. See ../features/modes.md § 4.4 for the full sizing spec.

Inside a framework#

Most frameworks treat custom elements like any other HTML element:

React 19+ — native support:

tsx
<web-ai-chat public-key='agent_pk_...' ai-agent-deployment-id='01900000-0000-7000-8000-000000000000' mode='inline' />

React < 19 — the element works but TypeScript doesn't know the custom type. Declare it:

ts
declare module 'react' {
  namespace JSX {
    interface IntrinsicElements {
      'web-ai-chat': {
        'public-key'?: string;
        mode?: 'floating' | 'inline' | 'standalone' | 'page' | 'modal' | 'drawer';
        theme?: 'auto' | 'light' | 'dark';
        // ... (full list in configuration.md)
      };
    }
  }
}

Vue 3 — works out of the box.

Svelte — works with <svelte:options>-less components.

Angular — add CUSTOM_ELEMENTS_SCHEMA to your module.

Framework-by-framework details: Framework integrations.


Version pinning#

The bundle URL https://cdn.help.ai/sdk/js/elements/latest/elements.js always serves the latest stable version. For reproducibility, pin to a specific version:

html
<script src="https://cdn.help.ai/v1/elements.js" defer></script>
<!-- or -->
<script src="https://cdn.help.ai/v1.2.0/elements.js" defer></script>

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 <script type="module">.

Programmatic mount#

html
<script type="module">
  // Pin an exact version for production (see Caching & versioning below).
  import { mount } from 'https://cdn.jsdelivr.net/npm/@helpai/elements@<version>/elements.esm.js';

  mount({
    publicKey: 'agent_pk_…',
    aiAgentDeploymentId: '…',
    presentation: { mode: 'inline' },
    target: document.querySelector('#chat'),
  });
</script>

Web component (declarative tag)#

The web-component bundle self-registers <web-ai-chat> on import:

html
<script type="module">
  import 'https://cdn.jsdelivr.net/npm/@helpai/elements@<version>/elements-web-component.esm.js';
</script>

<web-ai-chat public-key="agent_pk_…" ai-agent-deployment-id="…" mode="inline"></web-ai-chat>

File catalog (every dist/<brand>/ ships these)#

FilePurposeUse with
elements.jsIIFE bundle, defines window.HelpAIPlain <script> (no type="module")
elements.esm.jsStandalone ESM, fully bundled<script type="module"> import
elements-web-component.esm.jsStandalone ESM, registers <web-ai-chat><script type="module"> import (no JS)
index.mjs / web-component.mjstsdown ESM with bare imports (import "preact" etc.)Bundlers (Vite, webpack, esbuild)
react.mjs / react.d.tsReact kit: <HelpAI />, useHelpAI(), widget bundledReact apps, @helpai/elements/react
style.cssStandalone stylesheet — host-element tokens + layout<link rel="stylesheet">
loader.js, loader.min.jsGA-style queueing loader stubTag managers / analytics pipelines
index.d.tsTypeScript declarationsNPM consumers

CDN URL forms#

URLResolves to
https://cdn.help.ai/sdk/js/elements/latest/elements.jsIIFE on the brand CDN
https://cdn.help.ai/sdk/js/elements/latest/elements.esm.jsStandalone ESM on the brand CDN
https://cdn.jsdelivr.net/npm/@helpai/elements@<v>/elements.esm.jsStandalone ESM on jsDelivr (immutable per version)
https://cdn.jsdelivr.net/npm/@helpai/elements@<v>/elements-web-component.esm.jsWeb-component ESM on jsDelivr

Version pinning#

PatternBehavior
@1.4.0Exact version. Immutable, max-age cached forever.
@1, @1.4Semver-range — latest matching 1.x.x / 1.4.x.
@latestAlways the most recent published version.
@^1.4.0Caret range, just like in package.json.

Production tip. Pin a specific version for production traffic so a published patch can't surprise you. @latest is fine for playgrounds and internal tools.

Why not +esm? jsDelivr's /+esm flag tries to auto-bundle on demand and trips on bare-specifier resolution for some packages — Failed to resolve module specifier "preact" is the common symptom. Loading the pre-bundled elements.esm.js avoids that entirely: built once at release time, immutable forever, every dep inlined.


Caching, versioning & self-hosting#

Two delivery models. Pick by how much you control the URL the customer pastes.

Option 1 — public CDN, pinned version (no infra; the default today)#

Serve the published NPM package straight from jsDelivr, pinned to an exact version — swap <version> for the release you want (the immutable pinning the live demos use):

html
<script type="module" async src="https://cdn.jsdelivr.net/npm/@helpai/elements@<version>/elements-web-component.esm.js"></script>
  • 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: <link rel="preconnect" href="https://cdn.jsdelivr.net" crossorigin> (and optionally rel="modulepreload").

Point a CDN (cdn.help.ai, a Cloudflare/CloudFront origin, …) at the built dist/<brand>/ assets and give customers one URL that never changes:

html
<!-- The customer pastes this ONCE and never touches it again. -->
<script src="https://cdn.help.ai/sdk/js/elements/latest/elements.js" data-public-key="agent_pk_..." data-ai-agent-deployment-id="01900000-0000-7000-8000-000000000000" defer></script>

You ship updates behind that stable URL — the customer's page is frozen. The caching rule is the whole trick:

AssetCache-ControlWhy
The stable entry (elements.js)public, max-age=300, must-revalidateShort TTL so a deploy propagates in minutes.
Content-hashed chunks (*.<hash>.js)public, max-age=31536000, immutableHash 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 <script> — all splitting, hashing, and cache invalidation happen behind it. No snippet change, ever.

Why this needs your own CDN — not jsDelivr. On jsDelivr the version lives in the path (/@0.56.2/…). When you ship 0.56.3 every chunk URL changes — even a byte-identical vendor chunk gets /@0.56.3/vendor.<hash>.js, a guaranteed cache miss. So splitting on a version-pinned CDN trades one request for many of the same total bytes — worse. Cross-release chunk caching requires content-addressed URLs where the version is not in the path, i.e. a CDN you control. (Splitting still helps first-paint on either, via lazy features.)

Is it worth it? (usually no.) We built and measured this for the @helpai/elements bundle and reverted it: only ~10 % of an ~107 KB gz bundle is lazy-able (the rest is core chat that loads on first paint regardless), and the lazy import() needed to create a chunk is inlined into the single-file builds — so it adds ~1 KB there for no benefit. The cost lands on the builds that don't chunk. For a single self-contained file that's async + immutable-cached, the per-release re-download is a non-issue anyway. Reach for chunking only if your optional surfaces (tabs/forms/voice) grow large enough that lazy-loading them meaningfully shrinks first paint — and measure with an esbuild metafile first, don't split blind.

Data residency. Self-hosting (Option 2) also lets you keep asset delivery in a chosen region; the runtime still calls the agent API at the configured baseUrl.


What ships in the bundle#

  • Preact 11 + @preact/signals — reactive rendering primitives, ~6 KB combined gzipped.
  • streaming-markdown — incremental markdown renderer, ~3 KB.
  • Widget code — chat transcript, composer, messenger-module tabs (Home / Help / News), forms + HITL, config, themes — the bulk.
  • i18n — built-in locale strings (EN + bundled translations).
  • CSS — inlined into the bundle (no separate request).

Total ~107 KB gzipped for the IIFE / standalone ESM (see the live budgets in .size-limit.json, enforced in CI). No external runtime dependencies. No React, no jQuery, no analytics beacons. The bulk is widget code, not vendor — relevant when you weigh chunking (see Caching, versioning & self-hosting).


Next#

Still need help?

Ask the community