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#
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 uselatest; pin a version in production so a release never changes behaviour under you.
All four paths:
- Load the same JS bundle.
- Accept the same configuration options.
- Emit the same events.
- Respect the same isolation guarantees.
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.
<!-- 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:
Unknown attributes are ignored. Full option semantics: Configuration.
Explicit scalar attributes (
data-user-id,data-page-area, …) win over keys in thedata-user-context/data-page-contextJSON. For SPAs where the visitor logs in after load or navigates between pages, set context dynamically withinstance.setUserContext(...)/setPageContext(...). Thedata-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:
<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:
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.
<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#
Pre-boot hooks#
window.HelpAIHooks is a pre-boot queue for event listeners. Push
arrays or objects to it before the bundle arrives:
<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:
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#
npm install @helpai/elements
# or
yarn add @helpai/elements
# or
pnpm add @helpai/elements
# or
bun add @helpai/elementsEntry points#
The package ships two entries:
// 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#
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:
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#
<!-- 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.
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):
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.
<!-- ✓ 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:
<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:
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:
<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#
<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:
<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)#
CDN URL forms#
Version pinning#
Production tip. Pin a specific version for production traffic so a published patch can't surprise you.
@latestis fine for playgrounds and internal tools.Why not
+esm? jsDelivr's/+esmflag 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-bundledelements.esm.jsavoids 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):
<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 optionallyrel="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/<brand>/ assets and give customers one URL that never changes:
<!-- 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:
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:
- Build ESM with code-splitting on (esbuild/tsdown
splitting: true,format: "esm") and[hash]filenames. The thin entry imports the chunks. - 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. - 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 ship0.56.3every 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/elementsbundle 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 lazyimport()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'sasync+ 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#
- Configuration — every option explained with examples.
- Theming & customization — matching your brand.
- Framework integrations — framework-specific recipes.
- Troubleshooting — "my widget isn't showing up".