Skip to content

Recipes

Copy-paste solutions for common integrations.

Last updated

Copy-paste-ready solutions for common integration scenarios.


Contents#


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'],
  },
});

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.

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

Still need help?

Ask the community