> For clean Markdown of any page, append .md to the page URL.
> For a complete documentation index, see https://docs.ada.cx/llms.txt.
> For AI client integration (Claude Code, Cursor, etc.), connect to the MCP server at https://docs.ada.cx/_mcp/server.

# Security best practices

Part of the Messaging Web SDK runs inside your page and inherits your page's trust boundary: the loader and the interface it returns. The conversation runtime does not, as the next section explains. This page collects the practices that keep an integration safe: how to load the SDK, how to scope your references, which settings widen exposure, and how to handle identity tokens.

## What runs in your page, and what does not

Two different things carry the Ada name, and only one of them runs in your origin.

The **loader and the interface** run in your page. That is the code you import, the object you hold, and the event callbacks you register. Everything else on this page is about protecting that surface, because it does inherit your trust boundary.

The **conversation runtime** does not run in your page. The SDK mounts it in a cross-origin iframe served from the Ada asset host, under a Content Security Policy that Ada sets on that document. By default, AI Agent message bodies from the conversation stay behind that frame
boundary, as does the conversation runtime's own API and WebSocket traffic. Your page's policy does
not govern those requests. Several things cross by design, including the conversation transcript
on more than one path. Read the list below rather than assuming bodies stay inside.

The boundary is not total. The crossings below are the ones to plan for. Treat the list
as the significant cases rather than a closed set. Verify against your own policy
reports.

* **The end user token, always.** The SDK delivers it to your page whenever a new end
  user starts a conversation, through the `ada:chatter_token` event and the legacy
  `chatterTokenCallback` global. No setting gates this. Any script running in your page
  can read it, so treat it as a value your page already holds. See [Prefer module imports for new installations](#prefer-module-imports-for-new-installations).
* **The end-of-conversation transcript, always.** The `ada:end_conversation` event, and
  the legacy `conversationEndCallback` global, deliver the conversation transcript with
  the session and end user identifiers. No setting gates this either. See [Limit transcript exposure](#limit-transcript-exposure).
* **Live agent message bodies, always.** The SDK raises browser notifications for
  messages from a live agent, and the message body is delivered to your page to fill
  them. No setting gates this. Your page receives the body before the browser decides
  whether to show a notification, so a denied permission does not keep the body out.
  AI Agent messages are not delivered this way. See [Limit transcript exposure](#limit-transcript-exposure).
* **Every other message body, when you opt in.** `enableProgrammaticControl`
  exposes `getMessages()` and the `ada:message:sent` and `ada:message:received` events,
  each carrying full message content. See [Limit transcript exposure](#limit-transcript-exposure).
* **The transcript, whenever a Fire Event block runs.** If your AI Agent runs a Fire
  Event block, Ada enriches the event with the conversation transcript and the end
  user's variables. Ada delivers it to your page. No setting gates this, and registering
  no `eventCallbacks` does not prevent it: your page receives the payload first and the
  callback check happens after. Treat any AI Agent that fires events as one that sends
  transcripts to your page.
* **The whole live agent conversation, during a handoff.** Handoffs run in your page.
  Starting one can deliver the prior conversation to the integration. Each message the
  end user sends passes through your page on its way to the agent platform. Each message
  the agent sends comes back the same way. No setting gates this.
* **Handoff activity, as a DOM event.** During a handoff the SDK dispatches
  `ada:integration:event` on your page's `window`. For Zendesk Chat the payload carries
  the text of each agent message with the agent's display name. Any script on your page
  can read it with `addEventListener`, without holding a reference to the client. This is
  the most reachable item on this list. See [Limit transcript exposure](#limit-transcript-exposure).
* **Proactive campaign text, always.** The SDK passes a campaign's message text through
  your page to render it. For a static campaign this is text you already configured. A
  campaign that starts a playbook instead produces text your page did not supply. No
  script on your page can read either through an Ada event, and the campaign events
  carry no bodies.
* **Per-message metadata and conversation state, always.** `ada:conversation:message`
  publishes the sender, the message id and the conversation id on every message.
  `ada:conversation:change`, `ada:typing:start`, `ada:typing:stop`, `ada:agent:joined`,
  `ada:agent:left`, `ada:connection:change` and `ada:analytics` publish on the same
  ungated contract, and `getInfo()` and `getMetaFields()` read without a gate. None of
  these carry message bodies.
* **Network calls from your page.** The SDK clears the unread badge and reads
  notification status from your AI Agent's API host, so your `connect-src` must allow
  it. See [Content Security Policy](/messaging/web/getting-started#content-security-policy).

Two consequences are useful in a security review:

* Your host-page exposure is the asset host in three CSP directives, plus your AI Agent's API host in `connect-src`. See [Content Security Policy](/messaging/web/getting-started#content-security-policy).
* You do not need to allow the conversation runtime's API or WebSocket endpoints. That traffic originates inside the Ada iframe, under Ada's policy.

## Why the SDK loads the current build

The supported model is the current build, served from the Ada asset host. Ada does not support self-hosting the runtime or bundling it into your application.

The reason is patch latency. When Ada ships a security fix, every integration on the asset host receives it on the next page load. An integration that carries its own copy receives it only after someone notices the fix, reviews the change, rebuilds, and redeploys. That gap belongs to whoever owns the copy.

This is the standard model for embedded third-party scripts that handle sensitive interactions. Stripe.js, Plaid Link, and Google reCAPTCHA all require loading the current script from the vendor, for the same reason.

> **Warning**
>
> [Pinning a CDN build](/messaging/web/getting-started#pin-the-cdn-build) is a debugging and staged-validation tool. Do not treat it as a version-freezing strategy or as a security control. A pinned integration does not receive security fixes, and Ada cannot support it.
>
> The `ada-messaging-version` query parameter pins a build the same way. The SDK reads it from the page URL, and your page cannot disable it. A crafted link can therefore load an older deployed build for any visitor. The SDK validates the identifier's format and attempts to load it from Ada's asset host.

If your change-management process needs a review window, use these instead of a frozen dependency:

* **A published allowlist.** The asset host in three directives, plus your AI Agent's API host in `connect-src`. Your attack surface is explicit and reviewable, and `Content-Security-Policy-Report-Only` shows you anything the published list misses.
* **Iframe isolation.** The runtime is sandboxed off your origin, as described above. This is a stronger control than reviewing code that runs in your page.

## Prefer module imports for new installations

The `window.adaEmbed` and `window.adaSettings` globals remain fully supported for existing installs. That includes pages that load the SDK through the legacy `static.ada.support` loader path. Those pages need no code change. If your page uses legacy features, review the [remaining customization differences](/messaging/web/getting-started#migrate-from-the-chat-sdk-web-script).

For new installations, the globals are deprecated. New installations should import the SDK module from `https://messaging-assets.ada.support/sdk.js`, or install the [`@ada-cx/messaging-sdk`](/messaging/web/getting-started#install-from-npm) npm loader.

The reason is simple: a global is reachable by every script on the page. Analytics tags, ad pixels, session replay tools, and any compromised third-party script can read and call `window.adaEmbed`. A reference held in module scope is reachable only by your own code.

> **Note**
>
> The npm loader's `loadAdaMessaging` never assigns the interface to `window.adaEmbed` or any other global. Keep the returned reference in module scope, and do not create globals of your own.
>
> Module scope limits what you expose by accident. It is not an isolation boundary. The SDK keeps its own reference on the page to enforce one client per page. A determined script on your page can still reach the client. Treat every script you load as able to reach the conversation.

## Keep the client in a private scope

Never assign the client, or references derived from it, to `window` or any other global object. Derived references include the interface itself, transcripts returned by `getMessages()`, event payloads, and subscription ids. This reduces accidental exposure. It does not isolate the client from other scripts on your page, so control what you load rather than relying on scope.

Keep meta field methods in a private scope to avoid exposing them through globals. Scripts with a client reference can call `reset()`, `setMetaFields()`, and `setSensitiveMetaFields()`. Set [`allowMetaFieldsInReset: false`](/messaging/web/sdk-api-reference#allowmetafieldsinreset) to limit the meta fields applied during full resets. The setting does not restrict other meta field methods. With `adaEmbed`, calling `stop()` and then `start()` can create a client with different settings.

A `<script type="module">` block already gives you a private scope. A top-level `const` in a module is not visible to other scripts:

```html
<script type="module">
  import { createAdaEmbedInterface } from "https://messaging-assets.ada.support/sdk.js";

  const adaEmbed = createAdaEmbedInterface();
  await adaEmbed.start({ handle: "<your-handle>" });

  await adaEmbed.subscribeEvent("ada:end_conversation", (event) => {
    // handle the event with your own code
  });
</script>
```

### Classic scripts

Top-level `var` and `function` declarations in a classic `<script>` become properties of `window`. Wrap your integration code in an immediately invoked function expression so your state stays inside the closure:

```html
<script>
  (function () {
    let subscriptionId;

    function onConversationEnd(event) {
      // handle the event with your own code
    }

    window.adaSettings = {
      handle: "<your-handle>",
      onAdaEmbedLoaded: async () => {
        subscriptionId = await window.adaEmbed.subscribeEvent(
          "ada:end_conversation",
          onConversationEnd,
        );
      },
    };
  })();
</script>
```

`subscriptionId` and `onConversationEnd` are private to the closure. Only the `window.adaSettings` contract itself remains global, because the legacy loader requires it.

### Anti-pattern

Do not copy this pattern. It parks the client and a transcript on globals, where every script on the page can reach them:

```html
<!-- Anti-pattern: do not use -->
<script type="module">
  import { createAdaEmbedInterface } from "https://messaging-assets.ada.support/sdk.js";

  const adaEmbed = createAdaEmbedInterface();
  window.adaEmbed = adaEmbed; // any script can now drive the client
  await adaEmbed.start({ handle: "<your-handle>", enableProgrammaticControl: true });

  window.lastMessages = await adaEmbed.getMessages(); // transcript exposed to any script
</script>
```

## Limit transcript exposure

Live agent message bodies reach your page whatever you configure, to fill browser notifications. Treat them as data your page already holds. The settings below widen the exposure further. Both default to off, and both are locked on the first configuration.

* [`enableProgrammaticControl`](/messaging/web/sdk-api-reference#enableprogrammaticcontrol) unlocks `getConversation`, `getMessages`, [`selectQuickReply`](/messaging/web/sdk-api-reference#selectquickreply), `sendMessage`, `setComposerText`, and `setDelegate`, plus the `ada:message:sent` and `ada:message:received` events. Those events carry full message bodies, including any personal information end users type.
* [`headless`](/messaging/web/sdk-api-reference#headless) runs the session with no visual indicator. On a compromised page, an attacker can run conversation activity under the visiting end user's token without any user-facing signal.

> **Warning**
>
> Every script in your host page inherits your page's trust and can subscribe to the gated events or call the gated methods. Have your product and security teams accept that exposure explicitly before you enable either setting.

Two properties of the event system limit accidental exposure, but not a malicious script:

* The transcript-bearing events require exact subscriptions. They are never delivered through [`subscribeAll`](/messaging/web/sdk-api-reference#subscribeall) or prefix subscriptions.
* While `enableProgrammaticControl` is off, the gated methods reject and the events are not delivered.

Keep both settings off unless your integration needs them. Scope any subscriber callbacks inside a module or closure, as shown above, so the data they receive stays private.

## Protect identity tokens

An [`identityToken`](/messaging/web/sdk-api-reference#identitytoken) proves who an end user is. Treat it like a credential:

* Mint tokens only from your backend, and keep your API key server-side.
* Deliver tokens to the browser over HTTPS only.
* Never log a token, on the server or the client.
* Never place a token in a URL, query parameter, or fragment. The SDK sends it in a request body, never in a URL.
* Mint a fresh token per attempt. Tokens are single use and expire in 15 minutes, so a stored token has no value.
* Do not hold a token in a global variable. Fetch it, pass it to `start()` or `reset()`, and drop your reference.

See the [identity guide's security checklist](/messaging/identity/getting-started#security-checklist) for the full flow.