Getting started
Your handle’s Allowed websites list controls custom apps. In your Ada dashboard, go to Channels > Chat and 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.
This guide builds a custom conversation app, connects it to the Messaging runtime with @ada-cx/messaging-bridge, and points the Web SDK at it. Read the 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 > 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, anhttp:URL on a loopback host such ashttp://localhost:5173is also accepted. Otherhttp:URLs, relative URLs, anddata: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 ashttp://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*.localhostsubdomain). 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 onlyhttps://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
@ada-cx/messaging-bridge is live on npm. Versions before 1.0.0 are pre-GA setup releases; ask your Ada team before building on one.
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:
- Replace
https://your-site.comwith 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-Optionsheader from the app’s response.frame-ancestorsreplaces it. - For staging validation, also allow the Ada-provided asset host and your staging site origins.
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.
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 for the full surface, and 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:
Read state with plain keys, or use the STATE constants from the loaded module for typo-safe access:
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, usetextContent. - Never assign a body to
innerHTML, and never pass one todangerouslySetInnerHTML. - 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.
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:
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:
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:
The same settings work with adaEmbed.start() on the module-script path.
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:
appUrlmust be an absolutehttps:URL without embedded credentials, on an origin you control. For local development,http:is accepted on loopback hosts such aslocalhostand127.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 > 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. 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.
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:
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:
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:
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). 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() 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:
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:
Staging validation
To validate against a different asset host, pass its root to the bridge loader. Ada gives you the host value when you arrange a preproduction validation:
Use cdnBase for staging validation only. Production pages must keep the default.
Preview and pre-release builds are Ada-internal workflows. When your Ada team runs a joint
validation with a preview build, the preview core automatically selects its matching bridge.
You do not need a cdnBase override for that: when the page loads sdk.js from the
production asset root, the pairing is automatic.
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.
The loader accepts only these marker shapes. It 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.