# Recipes

> Copy-paste solutions for common integrations.

Copy-paste-ready solutions for common integration scenarios.

---

## Contents

- [Custom launcher button](#custom-launcher-button)
- [Programmatic control](#programmatic-control)
- [Multiple widgets on one page](#multiple-widgets-on-one-page)
- [SPA route changes](#spa-route-changes)
- [Inline chat inside a sidebar](#inline-chat-inside-a-sidebar)
- [Auto-open on a specific page](#auto-open-on-a-specific-page)
- [Close on specific user actions](#close-on-specific-user-actions)
- [Logged-in user personalization](#logged-in-user-personalization)
- [Analytics forwarding](#analytics-forwarding)
- [Error reporting](#error-reporting)
- [Custom storage adapter](#custom-storage-adapter)
- [Feature flags at runtime](#feature-flags-at-runtime)
- [A/B test the launcher](#ab-test-the-launcher)
- [Force a specific language](#force-a-specific-language)
- [Gate the widget behind consent](#gate-the-widget-behind-consent)
- [Conditionally show based on URL](#conditionally-show-based-on-url)
- [Ask the assistant from your own buttons](#ask-the-assistant-from-your-own-buttons)
- [Deep links in emails and docs](#deep-links-in-emails-and-docs)
- [Follow your site's light/dark](#follow-your-sites-lightdark)

---

## Custom launcher button

Hide the default FAB, trigger via your own UI:

```ts
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`:

```html
<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):

```tsx
// 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

```ts
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:

```ts
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 (`localStorage` namespaced by `widgetId`).
- Its own event stream.

Control individual widgets:

```ts
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(); // both
```

---

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

```tsx
// 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:

```tsx
useEffect(() => {
  const chat = init({ publicKey, aiAgentDeploymentId: '01900000-0000-7000-8000-000000000000' });
  return () => chat.destroy();
}, []);
```

---

## Inline chat inside a sidebar

```html
<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.

```tsx
// React
<aside className='chat-sidebar'>
  <web-ai-chat public-key={KEY} ai-agent-deployment-id={DEPLOYMENT_ID} mode='inline' />
</aside>
```

```vue
<!-- 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:

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

```ts
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:

```ts
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

```ts
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:

```ts
// 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:

```ts
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 context
```

`reset()` 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

```html
<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

```ts
import { hook } from '@helpai/elements';

hook('*', '*', (inst, event, data) => {
  window.analytics.track(`Chat ${event}`, {
    widgetId: inst.widgetId,
    ...(typeof data === 'object' && data !== null ? data : {}),
  });
});
```

### Mixpanel

```ts
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

```ts
import { hook } from '@helpai/elements';

hook('*', '*', (inst, event, data) => {
  posthog.capture(`chat:${event}`, { widgetId: inst.widgetId, data });
});
```

### Amplitude

```ts
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

```ts
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

```ts
import { datadogRum } from '@datadog/browser-rum';
import { hook } from '@helpai/elements';

hook('*', 'error', (inst, _event, err) => {
  datadogRum.addError(err, { widget: inst.widgetId });
});
```

### Rollbar

```ts
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)

```ts
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

```ts
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)

```ts
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:

```ts
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

```ts
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

```ts
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:

```ts
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:

```ts
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:

```ts
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

```ts
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:

```ts
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:

```ts
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.

```html
<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:

```text
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 send
```

The 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:

```html
<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](/sdk/events-and-hooks) — full event API
- [Configuration](/sdk/configuration) — every option
- [Troubleshooting](/sdk/troubleshooting) — when things go wrong
