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

# Getting started

> **Note**
>
> Your handle's **Allowed websites** list controls custom apps. In your dashboard, go to **Channels** > **Messaging**. Before your transition, use **Channels** > **Chat**. Add your app's origin. Until the list allows your origin, a configured `appUrl` is dropped with a console warning and Ada's default app mounts. Local development needs no entry when both your app and the embedding page run on loopback hosts. See [Local development](/messaging/custom-apps/getting-started#local-development).

This guide builds a custom conversation app, connects it to the Messaging runtime with [`@ada-cx/messaging-bridge`](https://www.npmjs.com/package/@ada-cx/messaging-bridge), and points the Web SDK at it. Read the [custom apps overview](/messaging/custom-apps/overview) first for the architecture and requirements.

## Before you start

* You need your AI Agent's handle.
* Add your app's origin to your handle's **Allowed websites** list. In your Ada dashboard, go to **Channels** > **Messaging**. Before your transition, use **Channels** > **Chat**. Custom apps stay disabled while the list has no entry that allows your origin. An empty list disables custom apps for the handle.
* The same list controls which websites can embed the chat widget. When the list has entries, keep every website that embeds the widget in it too. Hosting your app on a website that the list already allows needs no new entry.
* Host your app on an absolute `https:` URL, on an origin you control. For local development, an `http:` URL on a loopback host such as `http://localhost:5173` is also accepted. Other `http:` URLs, relative URLs, and `data:` URLs are rejected, and so are Ada-hosted origins.

### Local development

Two development setups exist, and only one needs configuration:

* **Pure local (zero configuration).** A loopback `appUrl`, such as `http://localhost:5173`, is allowed implicitly when the page that embeds the widget also runs on a loopback host (`localhost`, `127.0.0.1`, `[::1]`, or a `*.localhost` subdomain). No Allowed websites entry is needed. In Chrome and Firefox, you must grant one browser permission. See below.
* **Hybrid (deployed page, local app).** A deployed page gets no implicit loopback allowance. Expose your development server through an `https:` tunnel, and add the tunnel origin to Allowed websites. The dashboard accepts only `https://` entries. This setup needs no browser permission, and it works in every browser.

#### Grant the local network permission

Chrome 142 and later gate requests from public sites to loopback and local addresses behind a Local Network Access permission. Ada's core frame loads from a public Ada origin. Its navigation to your loopback `appUrl` is such a request. Ada's frames delegate the permission down the frame chain automatically when your `appUrl` is a loopback URL. Ada never delegates it for other `appUrl` values.

With the delegation in place, Chrome shows a permission prompt for your page. The prompt names your local page and asks to look for and connect to devices on your local network. Click **Allow**. Chrome remembers your choice for the site. You can change it later in **Site settings**, under the local network permissions.

Set `appUrl` in the settings that the page starts with. The browser fixes the delegation when the widget frames mount. An `appUrl` that arrives later cannot enable it.

If the permission is missing or denied, the browser blocks the navigation before your app loads. The widget then falls back to the default app with `handshake_timeout`. DevTools shows the blocked request as `ERR_BLOCKED_BY_LOCAL_NETWORK_ACCESS_CHECKS`.

Browser support differs:

* **Chrome and Edge** (Chromium 142 and later): prompt appears, click **Allow** once.
* **Firefox** (rolling out from version 149): a similar prompt appears.
* **Safari**: no Local Network Access gate today. The loopback path works without a prompt.

Ada delegates the loopback half of the permission only. An RFC 1918 address such as `192.168.1.10` is not a loopback host. Neither the `http:` nor `https:` form gets the delegation.

If you cannot grant the permission, use the hybrid `https:` tunnel setup instead. It avoids the permission entirely.

## Step 1: Install the bridge package

```sh
npm install @ada-cx/messaging-bridge
```

> **Note**
>
> [`@ada-cx/messaging-bridge`](https://www.npmjs.com/package/@ada-cx/messaging-bridge) is live on npm. Install the latest version.

The package carries TypeScript types, test mocks, and a thin loader. The bridge runtime always loads from Ada's CDN, so the npm version never pins the runtime.

## Step 2: Allow Ada to frame your app

Ada's core frame mounts your app inside an iframe. The core frame is served from Ada's asset host, and it sits inside your own page. Browsers check framing restrictions against **every** ancestor frame, so your app's ancestor chain contains both origins: Ada's asset host and the site that embeds the widget.

If your app's responses send no `X-Frame-Options` header and no `frame-ancestors` directive, browsers permit framing. No server change is needed.

If your app restricts framing, its `frame-ancestors` directive must allow Ada's asset hosts **and** every site origin that embeds the widget:

```
Content-Security-Policy: frame-ancestors https://messaging-assets.ada.support https://static.ada.support https://your-site.com
```

* Replace `https://your-site.com` with every origin where your pages embed the Ada widget.
* Add the directive to your existing policy. Do not replace your other directives.
* Remove any `X-Frame-Options` header from the app's response. `frame-ancestors` replaces it.
* For [staging validation](#staging-validation), also allow the asset host you validate against and your staging site origins.

The bridge runs in your app document and uses the Content Security Policy that you control.
Allow `https://messaging-assets.ada.support` and `https://static.ada.support` in that document's `script-src` and `connect-src` directives.

A blocked frame shows nothing inside the frame. The `app.initialize` handshake never arrives, the 15-second timeout fires, and Messaging falls back to the default app with a console warning. Always test your policy inside the real embed on a page that sets `appUrl`. Opening your app URL directly in a browser tab does not exercise `frame-ancestors`.

## Step 3: Connect to the bridge

Load the runtime, create a client, and complete the handshake. This path works with any framework, or none.

```ts
import { loadMessagingBridge } from "@ada-cx/messaging-bridge";

const bridge = await loadMessagingBridge();
const client = bridge.createBridgeClient();

// Load your app and send app.initialize within 15 seconds of frame mounting.
// This includes document load time.
// If the handshake does not arrive, core unmounts your frame.
client.sendEvent("app.initialize");

// Re-render when the transcript changes, and report what the end
// user has read. Only durable rows carry a cursor, so drive markRead
// from this subscription. markRead is debounced and monotonic.
const unsubscribe = client.subscribeKey("chat.messages", (messages) => {
  renderMessages(messages ?? []);
  for (const message of messages ?? []) {
    if (message.cursor) client.operations.markRead(message.cursor);
  }
});

// Send a user message. The handle resolves with the message row.
// It settles with the optimistic row, which has no cursor yet; the
// cursor arrives on the durable echo, which the subscription above
// sees. Never drive markRead from the settled row.
const handle = client.operations.sendMessage("Hello");
await handle.settled();

// Tear down when your app unmounts.
unsubscribe();
client.destroy();
```

`client.operations` wraps every supported event with the guards and correlation logic that Ada's own conversation app uses. `client.subscribeKey(key, callback)` fires only when one key's value changes. See the [Operations reference](/messaging/custom-apps/operations) for the full surface, and [State and events](/messaging/custom-apps/state-and-events) for when each state key updates.

`loadMessagingBridge()` memoizes the load, so repeated calls return the same promise. A failed load is forgotten, and a later call retries. The promise rejects with a `MessagingBridgeLoadError`. The error's `code` property identifies the failure:

| Code                      | Meaning                                                                                                           |
| ------------------------- | ----------------------------------------------------------------------------------------------------------------- |
| `invalid_cdn_base`        | The `cdnBase` option violates the HTTPS or loopback HTTP policy.                                                  |
| `missing_build_sha`       | The frame's `window.name` carries no Ada core build marker.                                                       |
| `invalid_build_sha`       | The frame-name marker matches no accepted build identifier form.                                                  |
| `dev_marker_not_loopback` | The frame uses the Ada-internal `dev` marker with a non-loopback asset host. Customer apps never set this marker. |
| `bridge_import_failed`    | The asset failed to load (network, CSP, 404).                                                                     |
| `bridge_module_invalid`   | The loaded module is not an Ada bridge build.                                                                     |

Read state with plain keys, or use the `STATE` constants from the loaded module for typo-safe access:

```ts
const agentName = client.getState()?.[bridge.STATE.CONFIG_BOT_NAME];
```

### Render conversation content safely

Treat every string in the display state as untrusted data. `message.body` is a plain string. It can contain end-user input, AI Agent output, and content from knowledge sources.

* Render bodies as text. In React, JSX text rendering (`<p>{message.body}</p>`) escapes them for you. In plain DOM code, use `textContent`.
* Never assign a body to `innerHTML`, and never pass one to `dangerouslySetInnerHTML`.
* If you convert bodies from Markdown to HTML, sanitize the HTML output before you insert it into the document.

Your app frame holds the whole transcript. A single injected script in that frame can read every message.

> **Warning**
>
> The frame runs on your app's real origin. If you host the app on the same origin as the page that embeds the widget, the app frame is same-origin with your host page. An injected script in the app can then read your host page's cookies and storage, and can call `window.adaEmbed`. Host the app on a dedicated origin to contain a compromised frame.
>
> A separate subdomain (`app.customer.com` under a widget on `www.customer.com`) blocks the storage and `window.adaEmbed` escalations, but it is still **same-site** with your host page. Cookies scoped to the parent domain (`Domain=.customer.com`, common in single sign-on setups) remain readable from the app frame, and the frame's credentialed requests to your APIs are same-site, so `SameSite` cookie protections do not constrain them. For full containment, host the app on a separate registrable domain, or keep your main site's session cookies host-only (no `Domain` attribute).

### React

The `./react` entry provides a provider and hooks. `createBridgeProvider` handles loading, the `app.initialize` handshake, and teardown for you:

```tsx
import { createBridgeProvider, useBridgeState } from "@ada-cx/messaging-bridge/react";

const AdaBridgeProvider = createBridgeProvider();

function Root() {
  return (
    <AdaBridgeProvider fallback={<Spinner />}>
      <MyChatUi />
    </AdaBridgeProvider>
  );
}

function MyChatUi() {
  const state = useBridgeState();
  return <MessageList messages={state?.["chat.messages"] ?? []} />;
}
```

`AdaBridgeProvider` accepts these optional props:

| Prop             | Behavior                                                                                                                                                 |
| ---------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `errorFallback`  | Renders when loading fails. Defaults to `fallback`, so a failed load can look like ongoing loading.                                                      |
| `fallback`       | Renders while the runtime loads.                                                                                                                         |
| `onError(error)` | Receives the rejection. Loader-detected failures use `MessagingBridgeLoadError`. Without this callback, the provider logs failures with `console.error`. |

Set `errorFallback` and `onError` to make CSP or network failures visible.

If your app lazily loads the route that renders `AdaBridgeProvider`, add a bare
`import "@ada-cx/messaging-bridge";` to your entry module. The loader then
snapshots the frame name at startup, before other code can overwrite `window.name`.

To own the lifecycle yourself, create a client and pass it to `BridgeProvider`. With an injected client, you send `app.initialize` and call `destroy()` yourself:

```tsx
import { BridgeProvider } from "@ada-cx/messaging-bridge/react";

<BridgeProvider client={client}>
  <MyChatUi />
</BridgeProvider>
```

The hooks are `useBridgeClient()`, `useBridgeState()`, and `useBridgeStateKey(key)`. `useBridgeStateKey` re-renders only when its key's value changes; `useBridgeState` re-renders on every state update. Reach the operations layer through `useBridgeClient().operations`.

## Step 4: Point the Web SDK at your app

Set `appUrl` in the configuration you pass to the Web SDK on your host page:

```ts
import { loadAdaMessaging } from "@ada-cx/messaging-sdk";

const ada = await loadAdaMessaging({
  handle: "<your-handle>",
  appUrl: "https://app.example.com/ada-chat/",
});
```

Set this once per surface, then [deploy UI updates to the hosted app](/messaging/custom-apps/overview#deploy-once-update-everywhere).

The same settings work with `adaEmbed.start()` on the [module-script path](/messaging/web/getting-started#embed-with-a-module-script).

> **Note**
>
> `appUrl` and `appUrlFallback` are read only from the configuration passed to the constructor or `start()`. They are not read from `window.adaSettings`.

Rules the runtime enforces:

* `appUrl` must be an absolute `https:` URL without embedded credentials, on an origin you control. For local development, `http:` is accepted on loopback hosts such as `localhost` and `127.0.0.1`. The SDK drops any other value with a console warning, and the default app mounts. Ada-hosted origins and the Messaging frame's own origin are also rejected.
* The URL's origin must be allowed by your handle's **Allowed websites** list. Add it in your Ada dashboard, under **Channels** > **Messaging**. Before your transition, use **Channels** > **Chat**. An entry that carries a path, query, or fragment does not authorize a custom app. Add the bare origin as its own entry. Exception: a loopback origin is allowed implicitly when the embedding page itself runs on a loopback host. See [Local development](#local-development). A URL that the list does not allow follows the fallback rules below, with the code `origin_not_allowlisted`.
* The value locks on first configuration. A later configuration change or `reset()` cannot redirect the app frame to a different URL.
* Ada appends no query parameters to your URL. Your app receives all state through the bridge.
* Your app must not navigate or reload its own frame. The bridge is pinned to the first document, and a navigation disconnects it permanently. This applies even when the navigation stays on the same origin. To show other pages inside your app, mount your own inner iframe instead.

To request desktop frame dimensions from your custom app, use [`client.operations.setFrameStyles`](/messaging/custom-apps/operations#setframestyles).
The operation also supports frame appearance and an empty-object reset.

## Step 5: Choose your fallback posture

By default, Ada replaces a failed custom frame with the default app. This happens when the frame fails to load, fails its handshake, or fails the Allowed websites check. It also happens when your handle's configuration does not load in time to verify the Allowed websites list. Set `appUrlFallback: false` to fail closed instead:

```ts
await loadAdaMessaging({
  handle: "<your-handle>",
  appUrl: "https://app.example.com/ada-chat/",
  appUrlFallback: false,
});
```

With fallback disabled, the failure follows the normal activation error path, and later activation attempts retry your URL. In this mode, the `ada.customApp.fallback` event does not fire. `appUrlFallback` is only meaningful together with `appUrl`, and it also locks on first configuration.

## Step 6: Watch for fallbacks

Subscribe to the `ada.customApp.fallback` diagnostic event on your host page. It fires when a custom frame is abandoned, with the attempted URL and the cause:

```ts
await ada.subscribeEvent("ada.customApp.fallback", (data) => {
  // data: { appUrl: string, reason: string, code: string, retryable: boolean }
  reportToYourTelemetry("ada-custom-app-fallback", data);
});
```

Branch on `code`, the stable machine-readable cause. The `reason` string is human-readable and can change between releases. `code` has one of these values:

| Code                     | Meaning                                                                                                                                                                        | `retryable` |
| ------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ----------- |
| `origin_not_allowlisted` | The URL's origin is not allowed by your handle's Allowed websites list. An entry that carries a path, query, or fragment does not authorize a custom app. Add the bare origin. | `false`     |
| `allowlist_unavailable`  | Your handle's configuration did not load in time to verify the Allowed websites list.                                                                                          | `true`      |
| `frame_load_failed`      | Your frame's document was still loading after 15 seconds.                                                                                                                      | `false`     |
| `handshake_timeout`      | Your frame produced a document, but the `app.initialize` handshake did not complete within 15 seconds.                                                                         | `false`     |

A `handshake_timeout` does not prove that your app code ran. Browsers count an HTTP error page as a produced document. They also count a page they blocked with `X-Frame-Options` or `frame-ancestors`, and a Local Network Access error page for a blocked loopback `appUrl` (see [Grant the local network permission](#grant-the-local-network-permission)). Check the URL, your framing headers, and the browser permission first, then your bridge client.

When `retryable` is `false`, the fallback is permanent for the page. The default app serves until the page reloads with the cause fixed. When `retryable` is `true`, the next mount after a [`reset()`](/messaging/web/sdk-api-reference#reset) verifies your URL against the loaded Allowed websites list again.

Wire this into your monitoring before launch. A fallback is invisible to end users when `appUrlFallback` is on, so this event is your only signal. The event fires only when a fallback runs. With `appUrlFallback: false`, it never fires, and failures surface through the activation error path.

## Step 7: Test your app

The `./testing` entry provides a scriptable in-memory client. It performs no postMessage and no network access:

```ts
import { createMockBridgeClient } from "@ada-cx/messaging-bridge/testing";

const mock = createMockBridgeClient();
render(<BridgeProvider client={mock}><MyChatUi /></BridgeProvider>);

mock.updateState({ "chat.isSending": true });
expect(mock.sentEvents).toContainEqual({
  event: "chat.message.send",
  payload: expect.objectContaining({ body: "Hello" }),
});
```

Mock helpers: `clearSentEvents`, `destroyed`, `sentEvents`, `setState`, `subscriberCount`, and `updateState`.

The mock implements the full client surface, `operations` included. Every operation records its events in `sentEvents`, and a correlated handle resolves when your test scripts the answering state change:

```ts
const handle = mock.operations.sendMessage("Hello");
mock.updateState({
  "chat.messages": [
    { type: "text", id: "m1", sender: "user", timestamp: 1,
      body: "Hello", clientKey: handle.tempMessageUuid },
  ],
});
await handle.settled();
```

## Staging validation

To validate against a different asset host, pass its root to the bridge loader:

```ts
const bridge = await loadMessagingBridge({
  cdnBase: "<asset-host>",
});
```

The preferred production root is `https://messaging-assets.ada.support/`.
For production pages whose CSP already allows `static.ada.support`, use `cdnBase: "https://static.ada.support/messaging/"`.
Without `cdnBase`, the bridge loader retries once from the static root when the default import fails, including CSP and network failures.
This fallback requires your app document's policy to allow `https://static.ada.support` in `script-src` and `connect-src`.
The retry preserves the mounting core's build identifier.
An explicit `cdnBase` disables automatic fallback.

The package exports the static root as `FALLBACK_CDN_BASE`. Pass `cdnBase: FALLBACK_CDN_BASE` to load from it directly.
`resolveBridgeCdnUrl({ cdnBase })` returns the requested bridge URL for this frame. Use it to configure CSP.
It reports the configured root, not the root used by a fallback.
If both imports fail, `error.cause` holds the first failure and `error.fallbackCause` holds the static-root failure.

Module-invalid and validation errors retain their original codes and messages.
If both imports fail, the loader rejects with `bridge_import_failed` and names both URLs.

After a successful fallback, the loader logs one warning per page, without debug logging.
The warning includes the original error as its second console argument.
A `TypeError` from the default import produces the fetch-failure warning:

```text
[Ada] Loaded the Messaging bridge from https://static.ada.support/messaging/ because the default root https://messaging-assets.ada.support/ was blocked or unavailable. Add https://messaging-assets.ada.support to script-src, connect-src and frame-src, or pass cdnBase: "https://static.ada.support/messaging/".
```

For a CSP block, update your host's policy as shown in the fetch-failure warning.

Other errors produce the evaluation-failure warning:

```text
[Ada] Loaded the Messaging bridge from https://static.ada.support/messaging/ because the module at https://messaging-assets.ada.support/<build identifier>/bridge/bridge.js failed to evaluate (Error: boom). This is an Ada-side problem, not your host's CSP. Report it to Ada.
```

For an evaluation-failure warning, report the original error to Ada.
Network failures also produce the fetch-failure warning.
A module that throws `TypeError` during evaluation also produces the fetch-failure warning, so the classification does not prove a CSP block.

Successful loads from the requested root produce no fallback warning.
The `cdnBase` value must name an Ada-operated host.
It does not affect where you host your app.
Host your app on an origin you control.

### Match the core build

Ada core stamps its build identifier into your frame's `window.name` as `ada-custom-app:<build identifier>` before your app code runs. Your URL and its fragment mount untouched, so hash routes keep working.

| Marker                              | Meaning                                                                     | Resolved asset                                 |
| ----------------------------------- | --------------------------------------------------------------------------- | ---------------------------------------------- |
| A bare 40-character hexadecimal SHA | An immutable production build.                                              | `<cdnBase>/<sha>/bridge/bridge.js`             |
| `dev`                               | An Ada-internal local core build. Customer pages never receive this marker. | `<cdnBase>/bridge/bridge.js` on loopback only. |

The loader never accepts a URL or path from the frame name.

The loader snapshots `window.name` when your app first evaluates the package, through either entry: `@ada-cx/messaging-bridge` or `@ada-cx/messaging-bridge/react`. Keep that import in a module your page evaluates at startup. A route-level code split evaluates too late. Code that runs first, for example a library that uses `window.name`, could overwrite the name. The loader keeps the snapshot value if code overwrites `window.name` later.

Do not persist or replay the frame-name marker. Core supplies the authoritative value for every frame load.

Do not set or change `window.name`. The loader fails closed when your page runs outside an Ada-mounted custom app frame.

Local core builds use the `dev` marker. This marker works only with a loopback `cdnBase`.