Skip to content

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#

MechanismScopeWhen to use
Instance events — chat.on(event, fn)One widget you hold a reference toCustom 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#

EventFires onPayload
openPanel opens (floating mode)void
closePanel closesvoid
messageUser sends a message, or a reply from the AI agent or a team member finishes{ role: "user" | "assistant" | "operator"; text: string }
errorTransport error or mid-stream errorunknown (error object or message)
expandExpanded tier toggled (⤢)boolean (new state)
fullscreenFullscreen tier toggled (⛶)boolean (new state)
handshakeWidget finishes booting (once per mount)HandshakeResponse (dashboard config)
localeChangeUser picks a different languagestring (new BCP-47 tag)
messageFeedbackUser rates a reply 👍/👎 (or clears it){ messageId: string; value: "up" | "down" | null }
hostEventYour page calls chat.track(name, data){ name: string; data?: Record<string, scalar> }
toolResultA page tool 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)
ScopeOne widgetAny matching widget(s)
Callback signature(payload) => void(instance, event, payload) => void
Needs the instance handleYesNo — works with zero-JS embeds
Survives destroy()NoYes — registry is page-scoped
Pre-boot queueableNoYes

Pre-boot hooks#

If you use the async loader snippet, you can register hooks before the bundle arrives. Push tuples onto window.HelpAIHooks:

html
<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").

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
<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#

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#

SymptomLikely cause
open never firesWidget is in inline/standalone mode — it's "always open"
handshake never firesNetwork error on /pai/handshake. error fires instead
message fires twice per user sendCorrect — 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 firesWidget never mounted — check the public-key/deployment-id pair

Troubleshooting has the full diagnostic checklist.


Next#

Still need help?

Ask the community