Recipes
Copy-paste solutions for common integrations.
Last updated
Copy-paste-ready solutions for common integration scenarios.
Contents#
- Custom launcher button
- Programmatic control
- Multiple widgets on one page
- SPA route changes
- Inline chat inside a sidebar
- Auto-open on a specific page
- Close on specific user actions
- Logged-in user personalization
- Analytics forwarding
- Error reporting
- Custom storage adapter
- Feature flags at runtime
- A/B test the launcher
- Force a specific language
- Gate the widget behind consent
- Conditionally show based on URL
- Ask the assistant from your own buttons
- Deep links in emails and docs
- Follow your site's light/dark
Custom launcher button#
Hide the default FAB, trigger via your own UI:
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:
<a href="/help" data-helpai-open>Chat with us</a> <a href="/help" data-helpai-open="help">Help articles</a>Badge your button with the widget's unread count (conversations with replies the visitor hasn't read):
// React
function ChatButton() {
const chat = useRef<ChatWidgetInstance | null>(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 (
<button onClick={() => chat.current?.open()} className='my-btn'>
Chat
{unread > 0 && <span className='badge'>{unread}</span>}
</button>
);
}Programmatic control#
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:
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 (
localStoragenamespaced bywidgetId). - Its own event stream.
Control individual widgets:
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(); // bothSPA 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.
// 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:
useEffect(() => {
const chat = init({ publicKey, aiAgentDeploymentId: '01900000-0000-7000-8000-000000000000' });
return () => chat.destroy();
}, []);Inline chat inside a sidebar#
<div style="display: flex; height: 100vh">
<aside style="width: 320px; border-right: 1px solid #eee">
<h2>Help</h2>
<web-ai-chat public-key="agent_pk_..." ai-agent-deployment-id="01900000-0000-7000-8000-000000000000" mode="inline" theme="auto"></web-ai-chat>
</aside>
<main style="flex: 1">
<!-- your app content -->
</main>
</div>
<script src="https://cdn.help.ai/sdk/js/elements/latest/elements.js" defer></script>The widget fills its parent container — 320px wide, full viewport height. Parent needs to have a defined size.
In React / Vue / etc.#
// React
<aside className='chat-sidebar'>
<web-ai-chat public-key={KEY} ai-agent-deployment-id={DEPLOYMENT_ID} mode='inline' />
</aside><!-- Vue -->
<aside class="chat-sidebar">
<web-ai-chat public-key="agent_pk_..." ai-agent-deployment-id="01900000-0000-7000-8000-000000000000" mode="inline" />
</aside>Parent 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#
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:
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#
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:
// 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:
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 contextreset() 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#
<script>
window.HelpAIHooks = window.HelpAIHooks || [];
window.HelpAIHooks.push([
'*',
'*',
(inst, event, data) => {
gtag('event', `chat_${event}`, {
widget_id: inst.widgetId,
payload: typeof data === 'string' ? data : JSON.stringify(data).slice(0, 100),
});
},
]);
</script>
<script src="https://cdn.help.ai/sdk/js/elements/latest/elements.js" defer></script>Segment#
import { hook } from '@helpai/elements';
hook('*', '*', (inst, event, data) => {
window.analytics.track(`Chat ${event}`, {
widgetId: inst.widgetId,
...(typeof data === 'object' && data !== null ? data : {}),
});
});Mixpanel#
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#
import { hook } from '@helpai/elements';
hook('*', '*', (inst, event, data) => {
posthog.capture(`chat:${event}`, { widgetId: inst.widgetId, data });
});Amplitude#
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#
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#
import { datadogRum } from '@datadog/browser-rum';
import { hook } from '@helpai/elements';
hook('*', 'error', (inst, _event, err) => {
datadogRum.addError(err, { widget: inst.widgetId });
});Rollbar#
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)#
import { init, type ChatWidgetOptions } from '@helpai/elements';
type StorageAdapter = NonNullable<ChatWidgetOptions['storage']>;
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#
import { init, type ChatWidgetOptions } from '@helpai/elements';
type StorageAdapter = NonNullable<ChatWidgetOptions['storage']>;
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)#
const mem = new Map<string, string>();
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:
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#
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#
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:
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:
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:
const mem = new Map<string, string>();
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#
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:
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:
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.
<button data-helpai-ask="Which plan fits a team of 10?">Help me choose</button> <button data-helpai-ask>Do you ship to Canada?</button>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:
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 sendThe 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:
<web-ai-chat public-key="agent_pk_..." ai-agent-deployment-id="..." theme="host"></web-ai-chat>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 — full event API
- Configuration — every option
- Troubleshooting — when things go wrong