Chatbot widget embedding

Embed the Nerva chatbot widget on your website with a small script bundle and public widget preinit API.

The Nerva chatbot widget is a lightweight JavaScript runtime that mounts a floating chat launcher on your site. It runs inside a Shadow DOM so your page styles stay isolated from the widget UI.

Quick install

Paste this snippet into your page:

<script type="text/javascript">
(function(d, t) {
  var m = d.createElement(t),
      s = d.getElementsByTagName(t)[0];
 
  m.onload = function () {
    window.ctplai.cbp.init({
      agentId: "agent_xxxxxxxxx"
    });
  };
 
  m.src = "https://cdn.ctplai.org/crm.cbp.min.js";
  m.type = "text/javascript";
  s.parentNode.insertBefore(m, s);
})(document, "script");
</script>

Replace agent_xxxxxxxxx with your chat agent ID from Dashboard → Agents → your chat agent → Embed.

This loads the widget runtime, then initializes the chat agent once the script is ready.

How it works

  • The script bundle is loaded from the CDN.
  • Once loaded, window.ctplai.cbp.init() fetches widget data from /api/widget/[agentId]/preinit.
  • The widget opens a launcher and connects to the chat backend over WebSocket.

The default runtime URL is controlled by NEXT_PUBLIC_WIDGET_SCRIPT_URL and falls back to https://cdn.ctplai.org/crm.cbp.min.js.

Script-tag install (legacy)

The runtime also supports a self-initializing form. If the page contains a script tag carrying data-agent-id, the widget boots itself after the browser's load event without any init() call:

<script
  src="https://cdn.ctplai.org/crm.cbp.min.js"
  data-agent-id="agent_xxxxxxxxx"
  defer
></script>
AttributeDefaultDescription
data-agent-id(required)The chat agent ID. Its presence is what triggers auto-init.
data-auto-openfalseOpen the panel immediately once the widget mounts.
data-positionbottom-rightbottom-right, bottom-left, top-right, or top-left.
data-idle-timeout-ms(agent setting)Override the idle timeout for this embed.
data-idle-timeout-message(agent default)Message shown when the session times out.

Prefer the init() snippet above for new installs: it is what the dashboard generates, and it gives you the full JavaScript API. The data- form is kept for existing embeds and is not scheduled for removal.

Deferring the widget

init() runs as soon as you call it, so to delay the widget you delay the call. Initializing on a user action is the usual approach:

<button id="get-help-button">Get help</button>
<script>
document.getElementById("get-help-button").addEventListener("click", function() {
  window.ctplai.cbp.init({
    agentId: "agent_abc123"
  });
  window.ctplai.cbp.open();
});
</script>

Two related options are often confused with this:

  • autoOpen (default false) controls whether the chat panel opens by itself once the widget has mounted. It does not affect when init() runs.
  • openDelay (default 0, in milliseconds) delays only that automatic open. It has no effect unless autoOpen is true.
// Mount on load, then pop the panel open 5 seconds later.
window.ctplai.cbp.init({
  agentId: "agent_abc123",
  autoOpen: true,
  openDelay: 5000,
});

Widget API

After the script loads, the widget is available as window.ctplai.cbp. Common calls include:

window.ctplai.cbp.open();
window.ctplai.cbp.close();
window.ctplai.cbp.toggle();
window.ctplai.cbp.sendMessage("Hello");
window.ctplai.cbp.updateConfig({ locale: "fr" });
window.ctplai.cbp.destroy();
window.ctplai.cbp.isInitialized();

init() is a no-op if the widget is already initialized or currently initializing, so it is safe to call more than once.

Dashboard-driven appearance

Most widget settings are configured in the dashboard, not in the embed code. Adjust these from Dashboard → Agents → your chat agent → Appearance:

  • Brand color and button style
  • Bot avatar and launcher icon
  • Welcome message and placeholder text
  • Position, auto-open, and idle timeout
  • Lead capture form and file upload settings

The embed snippet only loads the runtime and initializes the agent.

Public widget preinit endpoint

The widget uses the public endpoint:

  • GET /api/widget/[agentId]/preinit

That endpoint returns the runtime configuration, the chat WebSocket URL, and agent-specific settings such as theme, position, idle timeout, and lead form behavior. It returns 404 when the agent does not exist or is not active, so an inactive agent simply never mounts.

Page context and visitor identity

When the widget opens its WebSocket it sends these query parameters:

ParameterDescription
sessionSession ID for this conversation thread.
visitorVisitor ID, stable across sessions in the same browser.
fingerprintFingerprintJS hash. Best-effort, may be empty.
localeBrowser locale, from navigator.language. Defaults to en.
pageUrlURL of the page the widget was opened on.

The chat backend also accepts pageReferrer and userId on this connection for callers that supply them. The bundled widget does not send either one, so pageReferrer arrives empty for standard embeds.

Downstream these arrive under their metadata names, so the value sent as locale appears as userLocale in webhook payloads. See Webhook events for the payload shapes.

Session state is stored in localStorage, keyed per agent, which lets returning visitors resume without your site managing user accounts.

Next.js / SSR usage

Use a plain script tag or Next.js Script component. The widget initializes in the browser after load, so it does not require server-side rendering of widget markup.

Because onLoad is a function prop, the component holding the Script has to be a client component:

"use client";
 
import Script from "next/script";
 
export default function ChatWidget() {
  return (
    <Script
      src="https://cdn.ctplai.org/crm.cbp.min.js"
      strategy="afterInteractive"
      onLoad={() => {
        window.ctplai.cbp.init({ agentId: "agent_abc123" });
      }}
    />
  );
}

Render <ChatWidget /> near the end of your root layout's <body>. Keeping it in its own component avoids marking the whole layout "use client".

TypeScript does not know about window.ctplai, so declare it once:

// types/ctplai.d.ts
declare global {
  interface Window {
    ctplai: {
      cbp: {
        init: (config: { agentId: string; [key: string]: unknown }) => void;
        open: () => void;
        close: () => void;
        toggle: () => void;
        sendMessage: (message: string) => void;
        updateConfig: (config: Record<string, unknown>) => void;
        destroy: () => void;
        isInitialized: () => boolean;
      };
    };
  }
}
 
export {};

Content Security Policy

If you use a Content Security Policy, allow the CDN that serves the bundle, the app origin the widget calls for preinit, and the voice host it opens the WebSocket against:

script-src 'self' https://cdn.ctplai.org;
connect-src 'self' https://app.nerva.ctplai.org wss://voice.nerva.ctplai.org;

The Quick install snippet is an inline script, which script-src blocks unless you allow it. Either give that tag a nonce:

<script nonce="YOUR_NONCE" type="text/javascript">
  /* ... */
</script>
script-src 'self' 'nonce-YOUR_NONCE' https://cdn.ctplai.org;

or avoid inline entirely by using the script-tag install, which needs no inline code.

Substitute your own hosts if you run Nerva on a different domain. The WebSocket host is whatever your deployment advertises in the preinit response, not a value you set in the embed.

Testing locally

The widget does not hardcode a chat host. It connects to the chatWsUrl returned by the preinit endpoint, which the app builds from its server-side NERVA_VOICE_WS_URL environment variable. To point a local widget at a local voice service, set NERVA_VOICE_WS_URL on the app (for example ws://localhost:8001) rather than changing the embed snippet.

To load a locally built bundle instead of the CDN one, override NEXT_PUBLIC_WIDGET_SCRIPT_URL on the app, or point m.src at your local file directly.

Troubleshooting

SymptomLikely cause
Widget never appearsScript failed to load, network/CSP blocked, or the agentId is invalid. An inline snippet under a strict script-src is the most common cause.
Widget loads but won't connectPreinit or WebSocket request blocked. Check connect-src covers both the app origin and the wss:// host, then check the browser Network tab.
Preinit returns 404The agent ID is wrong, or the agent is not active. Verify in the dashboard.
Widget position is wrongUse the agent's appearance settings or call window.ctplai.cbp.destroy() on specific pages.
Hydration warnings in Next.jsUse Script with afterInteractive or a plain script tag outside client-rendered JSX.

See also

On this page