Events and hooks
Every event and its payload, on() and hook().
Last updated
Listen for chat interactions, forward them to analytics, trigger custom UX.
Two mechanisms#
Both deliver the same event stream. Both are safe to mix.
Available events#
Instance events#
If you hold the ChatWidgetInstance returned from init() / mount(),
subscribe with .on():
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():
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:
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.
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()#
Pre-boot hooks#
If you use the async loader snippet, you can register hooks before
the bundle arrives. Push tuples onto window.HelpAIHooks:
<script>
window.HelpAIHooks = window.HelpAIHooks || [];
window.HelpAIHooks.push([
'*',
'*',
(inst, event, data) => {
analytics.track(`chat.${event}`, { widgetId: inst.widgetId, data });
},
]);
window.HelpAIHooks.push([
'support',
'open',
(inst) => {
mixpanel.track('support_opened');
},
]);
</script>
<!-- Widget loads below; hooks fire on its first event -->
<script src="https://cdn.help.ai/sdk/js/elements/latest/elements.js" defer></script>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").
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:
- On user send —
role: "user". Fires before the network round-trip. - On assistant finish —
role: "assistant". Fires when the finalfinishframe arrives (stream complete).
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:
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).
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:
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.
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.
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#
<script>
window.HelpAIHooks = window.HelpAIHooks || [];
window.HelpAIHooks.push([
'*',
'*',
(inst, event, data) => {
gtag('event', `chat_${event}`, {
widget_id: inst.widgetId,
payload: JSON.stringify(data).slice(0, 100),
});
},
]);
</script>
<script src="https://cdn.help.ai/sdk/js/elements/latest/elements.js" defer></script>Forward to Segment#
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#
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#
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#
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#
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#
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#
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:
- Per-instance
.on()listeners fire in registration order. - Global
hook()listeners fire in registration order. - 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 finishUnsubscribe hygiene#
Every subscription returns an off() function. Store and call them
on cleanup to avoid memory leaks:
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:
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#
Troubleshooting has the full diagnostic checklist.
Next#
- Analytics integration — GA4, Mixpanel, Segment, Amplitude, PostHog, Sentry, multi-provider
- Recipes — copy-paste scenarios
- API reference § events — compact lookup
- Configuration § callbacks