# Events and hooks

> Every event and its payload, on() and hook().

Listen for chat interactions, forward them to analytics, trigger
custom UX.

---

## Two mechanisms

| Mechanism                                             | Scope                                      | When to use                                    |
| ----------------------------------------------------- | ------------------------------------------ | ---------------------------------------------- |
| **Instance events** — `chat.on(event, fn)`            | One widget you hold a reference to         | Custom 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

| Event             | Fires on                                                                            | Payload                                                                                                                                    |
| ----------------- | ----------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------ |
| `open`            | Panel opens (floating mode)                                                         | `void`                                                                                                                                     |
| `close`           | Panel closes                                                                        | `void`                                                                                                                                     |
| `message`         | User sends a message, or a reply from the AI agent or a team member finishes        | `{ role: "user" \| "assistant" \| "operator"; text: string }`                                                                              |
| `error`           | Transport error or mid-stream error                                                 | `unknown` (error object or message)                                                                                                        |
| `expand`          | Expanded tier toggled (⤢)                                                           | `boolean` (new state)                                                                                                                      |
| `fullscreen`      | Fullscreen tier toggled (⛶)                                                         | `boolean` (new state)                                                                                                                      |
| `handshake`       | Widget finishes booting (once per mount)                                            | `HandshakeResponse` (dashboard config)                                                                                                     |
| `localeChange`    | User picks a different language                                                     | `string` (new BCP-47 tag)                                                                                                                  |
| `messageFeedback` | User rates a reply 👍/👎 (or clears it)                                             | `{ messageId: string; value: "up" \| "down" \| null }`                                                                                     |
| `hostEvent`       | Your page calls `chat.track(name, data)`                                            | `{ name: string; data?: Record<string, scalar> }`                                                                                          |
| `toolResult`      | A [page tool](/sdk/page-tools) 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)`          |
| ------------------------- | -------------------- | ------------------------------------ |
| Scope                     | One widget           | Any matching widget(s)               |
| Callback signature        | `(payload) => void`  | `(instance, event, payload) => void` |
| Needs the instance handle | Yes                  | No — works with zero-JS embeds       |
| Survives `destroy()`      | No                   | Yes — registry is page-scoped        |
| Pre-boot queueable        | No                   | Yes                                  |

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

| Symptom                             | Likely cause                                                                                             |
| ----------------------------------- | -------------------------------------------------------------------------------------------------------- |
| `open` never fires                  | Widget is in inline/standalone mode — it's "always open"                                                 |
| `handshake` never fires             | Network error on `/pai/handshake`. `error` fires instead                                                 |
| `message` fires twice per user send | Correct — 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 fires           | Widget never mounted — check the public-key/deployment-id pair                                           |

[Troubleshooting](/sdk/troubleshooting) has the full diagnostic
checklist.

---

## Next

- [Analytics integration](/sdk/analytics) — GA4, Mixpanel, Segment, Amplitude, PostHog, Sentry, multi-provider
- [Recipes](/sdk/recipes) — copy-paste scenarios
- [API reference § events](/sdk/api-reference#events) — compact lookup
- [Configuration § callbacks](/sdk/configuration#callbacks-shortcut-for-events)
