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 in LovelaceProvider.
  • 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:

1npm install @ada-cx/messaging-sdk

The @ada-cx packages are live on npm. Versions before 1.0.0 are pre-GA setup releases.

Two settings unlock this integration:

  • headless: true runs the SDK without rendering the default launcher button, intro popup, or chat drawer.
  • enableProgrammaticControl: true unlocks sendMessage(), getMessages(), getConversation(), and the ada:message:sent / ada:message:received events.

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.

1import { useEffect, useState } from "react";
2import { loadAdaMessaging } from "@ada-cx/messaging-sdk";
3import type { AdaMessagingInterface, PublicMessage } from "@ada-cx/messaging-sdk";
4import {
5 AgentMessage,
6 Input,
7 LovelaceProvider,
8 SendFill,
9 TypingIndicator,
10 UserMessage,
11 useSubmitOnEnter,
12} from "@ada-cx/lovelace";
13import "@ada-cx/lovelace/tokens.css";
14import "@ada-cx/lovelace/style.css";
15
16let adaPromise: Promise<AdaMessagingInterface> | undefined;
17
18function getAdaMessaging(handle: string): Promise<AdaMessagingInterface> {
19 adaPromise ??= loadAdaMessaging({
20 handle,
21 headless: true,
22 enableProgrammaticControl: true,
23 });
24 return adaPromise;
25}
26
27export function ChatPanel({ handle }: { handle: string }) {
28 const [ada, setAda] = useState<AdaMessagingInterface | null>(null);
29 const [messages, setMessages] = useState<PublicMessage[]>([]);
30 const [agentTyping, setAgentTyping] = useState(false);
31 const [draft, setDraft] = useState("");
32
33 useEffect(() => {
34 let active = true;
35 let embed: AdaMessagingInterface | undefined;
36 const subscriptions: number[] = [];
37
38 async function subscribe(
39 target: AdaMessagingInterface,
40 eventName: string,
41 handler: (event: unknown) => void,
42 ) {
43 const id = await target.subscribeEvent(eventName, handler);
44 if (active) {
45 subscriptions.push(id);
46 } else {
47 target.unsubscribeEvent(id);
48 }
49 }
50
51 async function connect() {
52 embed = await getAdaMessaging(handle);
53 if (!active) {
54 return;
55 }
56
57 const append = (event: unknown) => {
58 const { message } = event as { message: PublicMessage };
59 setMessages((current) =>
60 current.some((m) => m.id === message.id)
61 ? current
62 : [...current, message],
63 );
64 };
65
66 await subscribe(embed, "ada:message:sent", append);
67 await subscribe(embed, "ada:message:received", append);
68 await subscribe(embed, "ada:typing:start", () => setAgentTyping(true));
69 await subscribe(embed, "ada:typing:stop", () => setAgentTyping(false));
70 if (!active) {
71 return;
72 }
73
74 setMessages(await embed.getMessages());
75 setAda(embed);
76 }
77
78 connect().catch((error) => {
79 console.error("Ada failed to connect", error);
80 });
81 return () => {
82 active = false;
83 for (const id of subscriptions) {
84 embed?.unsubscribeEvent(id);
85 }
86 };
87 }, [handle]);
88
89 const send = () => {
90 const body = draft.trim();
91 if (ada === null || body === "") {
92 return;
93 }
94 setDraft("");
95 ada.sendMessage(body).catch(() => {
96 setDraft(body);
97 });
98 };
99 const onComposerKeyDown = useSubmitOnEnter(send);
100
101 return (
102 <LovelaceProvider theme="auto">
103 <div role="log" aria-label="Conversation">
104 {messages.map((message) =>
105 message.role === "user" ? (
106 <UserMessage key={message.id}>{message.body}</UserMessage>
107 ) : (
108 <AgentMessage key={message.id}>{message.body}</AgentMessage>
109 ),
110 )}
111 {agentTyping && <TypingIndicator />}
112 </div>
113 <Input
114 variant="composer"
115 aria-label="Message"
116 value={draft}
117 onChange={setDraft}
118 isDisabled={ada === null}
119 >
120 <Input.Field>
121 <Input.TextArea
122 placeholder="Type a message"
123 maxRows={4}
124 onKeyDown={onComposerKeyDown}
125 />
126 <Input.Button label="Send message" onPress={send}>
127 <SendFill />
128 </Input.Button>
129 </Input.Field>
130 </Input>
131 </LovelaceProvider>
132 );
133}

How the pieces connect:

  • loadAdaMessaging(settings) loads the Messaging runtime from Ada’s CDN, starts it with your settings, and resolves the programmatic interface.
  • getAdaMessaging shares one module-level promise. Only one interface can run on a page, so a second loadAdaMessaging() call rejects with start_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 subscribe helper records each subscription only while the effect is still active, and unsubscribes ids that resolve after cleanup. This prevents leaked handlers on unmount.
  • send restores the draft when sendMessage rejects, so a frame timeout does not discard the user’s text.
  • ada:message:sent and ada:message:received each deliver { message: PublicMessage }. The append handler dedupes by message.id, so the initial getMessages() transcript and live events never double-render.
  • ada:typing:start and ada:typing:stop deliver { agentId: string }. They fire when a human agent types, which is exactly what TypingIndicator represents.
  • PublicMessage.role marks who sent each message. The example renders "user" as UserMessage and every other role as AgentMessage. 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.
  • useSubmitOnEnter gives 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.

1npm install @ada-cx/messaging-bridge

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.

1import {
2 createBridgeProvider,
3 useBridgeClient,
4 useBridgeStateKey,
5} from "@ada-cx/messaging-bridge/react";
6import {
7 AgentMessage,
8 Input,
9 LovelaceProvider,
10 SendFill,
11 Spinner,
12 TypingIndicator,
13 UserMessage,
14 useSubmitOnEnter,
15} from "@ada-cx/lovelace";
16import { useState } from "react";
17
18const BridgeProvider = createBridgeProvider();
19
20export function App() {
21 return (
22 <BridgeProvider fallback={<Spinner size="lg" label="Connecting" />}>
23 <LovelaceProvider theme="auto">
24 <Transcript />
25 </LovelaceProvider>
26 </BridgeProvider>
27 );
28}
29
30function Transcript() {
31 const bridge = useBridgeClient();
32 const messages = useBridgeStateKey("chat.messages") ?? [];
33 const activeAgent = useBridgeStateKey("agent.activeAgent");
34 const [draft, setDraft] = useState("");
35
36 const send = () => {
37 const body = draft.trim();
38 if (body === "") {
39 return;
40 }
41 setDraft("");
42 bridge.sendEvent("chat.message.send", {
43 body,
44 messageType: "text",
45 tempMessageUuid: crypto.randomUUID(),
46 });
47 };
48 const onComposerKeyDown = useSubmitOnEnter(send);
49
50 return (
51 <>
52 <div role="log" aria-label="Conversation">
53 {messages.map((message) =>
54 message.sender === "user" ? (
55 <UserMessage key={message.id}>{message.body}</UserMessage>
56 ) : (
57 <AgentMessage key={message.id}>{message.body}</AgentMessage>
58 ),
59 )}
60 {activeAgent?.isTyping && <TypingIndicator />}
61 </div>
62 <Input
63 variant="composer"
64 aria-label="Message"
65 value={draft}
66 onChange={setDraft}
67 >
68 <Input.Field>
69 <Input.TextArea
70 placeholder="Type a message"
71 maxRows={4}
72 onKeyDown={onComposerKeyDown}
73 />
74 <Input.Button label="Send message" onPress={send}>
75 <SendFill />
76 </Input.Button>
77 </Input.Field>
78 </Input>
79 </>
80 );
81}

How the pieces connect:

  • useBridgeStateKey("chat.messages") returns the transcript as typed Message objects. message.sender marks who sent each message; the example renders "user" as UserMessage and every other sender as AgentMessage.
  • useBridgeStateKey("agent.activeAgent") carries the connected human agent, including its live isTyping flag.
  • bridge.sendEvent("chat.message.send", ...) submits a user message. The payload requires body, messageType: "text", and a fresh tempMessageUuid.
  • The state and event surfaces are fully typed. Explore AppDisplayState and AppEvents in 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