Build a custom chat UI
This guide builds a working chat surface from Lovelace components, driven by a live Ada conversation. There are two ways to get the conversation data:
- The headless Web SDK (
@ada-cx/messaging-sdk): your page owns the whole UI. The SDK connects in the background and exposes a programmatic API and events. Use this for in-page shapes such as a search bar, a command palette, or a sidebar. See the Messaging Web SDK docs. - The messaging bridge (
@ada-cx/messaging-bridge): Ada mounts your app in place of its default conversation UI. Your app receives conversation state and sends typed events. Use this to replace the widget’s interior while Ada keeps the launcher, frames, and session handling. See the custom apps docs.
Both examples below use the same four Lovelace components: AgentMessage, UserMessage, TypingIndicator, and the Input composer.
Prerequisites
- Complete the Lovelace getting started steps: install
@ada-cx/lovelace, import both stylesheets, and wrap your UI inLovelaceProvider. - Have your AI Agent handle and an approved domain for the page that hosts the UI.
Path 1: the headless Web SDK
Install the SDK loader package alongside Lovelace:
The @ada-cx packages are live on npm. Versions before 1.0.0 are pre-GA setup releases.
Two settings unlock this integration:
headless: trueruns the SDK without rendering the default launcher button, intro popup, or chat drawer.enableProgrammaticControl: trueunlockssendMessage(),getMessages(),getConversation(), and theada:message:sent/ada:message:receivedevents.
Every script in your host page (analytics tags, GTM, ad pixels, session replay tools, and any third-party SaaS pixel) inherits your page’s trust. Any of them can subscribe to these events or call these methods. They will see full message bodies, including any PII your end users type. Have your product and security teams accept that exposure risk explicitly before you turn on enableProgrammaticControl.
Headless sessions run with no visual indicator. The session token is created and persisted to local storage the same way as a visible session. Treat enabling headless as a meaningful trust decision for the host page.
The worked example
The component below connects, loads the transcript, renders it with Lovelace, and sends messages from a composer.
How the pieces connect:
loadAdaMessaging(settings)loads the Messaging runtime from Ada’s CDN, starts it with your settings, and resolves the programmatic interface.getAdaMessagingshares one module-level promise. Only one interface can run on a page, so a secondloadAdaMessaging()call rejects withstart_owner_conflict. React Strict Mode runs development effects twice and hits this without the shared promise. See one interface per page.connect().catch(...)handles a failed load or start. Without it, the rejection is unhandled and the composer stays disabled with no signal.- The
subscribehelper records each subscription only while the effect is still active, and unsubscribes ids that resolve after cleanup. This prevents leaked handlers on unmount. sendrestores the draft whensendMessagerejects, so a frame timeout does not discard the user’s text.ada:message:sentandada:message:receivedeach deliver{ message: PublicMessage }. Theappendhandler dedupes bymessage.id, so the initialgetMessages()transcript and live events never double-render.ada:typing:startandada:typing:stopdeliver{ agentId: string }. They fire when a human agent types, which is exactly whatTypingIndicatorrepresents.PublicMessage.rolemarks who sent each message. The example renders"user"asUserMessageand every other role asAgentMessage. The full role union ships in the package’s TypeScript declarations.- The transcript container is a single persistent
role="log"region, so screen readers announce new messages. The message components rely on the container for this. useSubmitOnEntergives the composer standard chat key handling: Enter sends, Shift+Enter inserts a newline, and IME composition never triggers a send.
This example renders text messages. Check message.type to handle other message kinds, for example rendering "picture" messages with PictureMessage.
The example uses the @ada-cx/messaging-sdk loader package. Pages that load Ada through a script tag can drive the same integration through the window.adaEmbed global with window.adaSettings = { handle, headless: true, enableProgrammaticControl: true }. Both paths are fully supported; the same methods and events apply.
Path 2: the messaging bridge
Your handle’s Allowed websites list gates custom apps: in your Ada dashboard, go to Channels > Chat and add your app’s origin. See Custom apps.
In a custom app frame, Ada owns the connection and your app owns the rendering. @ada-cx/messaging-bridge delivers conversation state and accepts typed events.
The React entry point wires everything for you. createBridgeProvider() returns a provider that loads the bridge runtime, connects to Ada’s core frame, and completes the required app.initialize handshake.
How the pieces connect:
useBridgeStateKey("chat.messages")returns the transcript as typedMessageobjects.message.sendermarks who sent each message; the example renders"user"asUserMessageand every other sender asAgentMessage.useBridgeStateKey("agent.activeAgent")carries the connected human agent, including its liveisTypingflag.bridge.sendEvent("chat.message.send", ...)submits a user message. The payload requiresbody,messageType: "text", and a freshtempMessageUuid.- The state and event surfaces are fully typed. Explore
AppDisplayStateandAppEventsin the package’s TypeScript declarations for handoffs, quick replies, read cursors, and more.
A custom app frame must send app.initialize within 15 seconds of loading, or Ada unmounts the frame. createBridgeProvider sends it for you. If you create the client yourself with loadMessagingBridge, send it explicitly.
Next steps
- Rebrand the surface with token overrides.
- Browse the component catalog for quick replies (
Chip), surveys (SurveyRating,SurveySelect), overlays, and more.