Operations

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.

client.operations provides typed helpers over the raw bridge event contract. Each helper carries the guards, debounces, and correlation logic that Ada’s own conversation app uses. Prefer these helpers over hand-built sendEvent calls. The raw events remain available as an escape hatch. See State and events for the underlying contract.

1const handle = client.operations.sendMessage("Hello");
2const message = await handle.settled({ timeoutMs: 10_000 });
3
4// settled() resolves with the optimistic row, which carries no cursor
5// yet. Report reads from a chat.messages subscription instead, over
6// the rows that carry one:
7client.subscribeKey("chat.messages", (messages) => {
8 for (const row of messages ?? []) {
9 if (row.cursor) client.operations.markRead(row.cursor);
10 }
11});

How correlation works

The bridge has no request/response channel. The core frame answers every action through display-state transitions. The operations layer hides this:

  • Fire-and-forget helpers return void. Core reports any outcome through state, or not at all.
  • Correlated helpers return a handle or a Promise. A handle’s settled(opts?) method returns a Promise that settles from the state transition that answers the operation.
  • settled({ timeoutMs }) rejects with an Error when the answer does not arrive in time. Without timeoutMs, an unanswered operation stays pending until you destroy the client. client.destroy() then rejects every pending settled() promise with a BridgeClientDestroyedError. Detect that rejection by error.name === "BridgeClientDestroyedError".
  • Core publishes a failure signal when it drops a correlated action instead of running it. This covers a host-driven reset in progress, a startup that cannot safely accept the action yet, a disabled transcript feature, and invalid submission arguments. Each affected handle settles with that failure rather than hanging. A partial CSAT submit is the one exception: it reports no terminal outcome by contract, so its drop is silent too. On an older core document without these signals, pass timeoutMs.

Each helper below states whether it is fire-and-forget or correlated, and which state transition settles it.

A cached copy of the bridge runtime can predate the operations layer. Feature-detect before use: check client.operations before calling it, and check typeof bridge.messageKey === "function" before using a derivation helper.

Conversation

addReaction

addReaction(messageId: string, reaction: 1 | -1): void;

Reacts to an AI Agent message with a thumbs vote. Fire-and-forget.

cancelCapture

cancelCapture(label?: string): void;

Cancels the active capture field. Uses the block’s own cancel response and label; label overrides the visible label. No-op when no capture is active or the block has no cancel response. Fire-and-forget.

cancelListSelection

cancelListSelection(label?: string): void;

Cancels the active selectable list. Uses the block’s own cancel response and label; label overrides the visible label. No-op when no list selection is active or the block has no cancel response. Fire-and-forget.

canRetryMessage

canRetryMessage(message: Message): boolean;

Returns whether the retry affordance should show for a message. True when the message is a text message that core marked isRetryable, no disconnected Zendesk SDK blocks it, and the message belongs to the current conversation or predates conversation stamping. Pure read; sends nothing.

reportLinkClick

reportLinkClick(messageId: string, opts?: { navigateInHost?: boolean }): void;

Reports that the user activated a link message. Core resolves the stored message for tracking. With navigateInHost, core also performs validated same-tab navigation on the host page. Fire-and-forget.

retryMessage

retryMessage(messageId: string): void;

Retries a failed send by message id. Gate the affordance on canRetryMessage. Fire-and-forget.

selectOption

selectOption(option: OptionItem, messageId: string): void;

Answers an options block. The label falls back to the option id when the option has no label. Fire-and-forget.

selectQuickReply

selectQuickReply(reply: QuickReply, messageId: string): void;

Answers a quick-reply chip. Pass the chip object and its message id verbatim from state. Fire-and-forget.

Before you enable any interactive row (quick replies, options, retry), consult isHistoricalRow and chat.answeredInteractiveIds. Answering a row from a previous conversation sends its target into the current one.

sendMessage

sendMessage(body: string, opts?: { secret?: boolean }): SendHandle;

Sends a chat message. Correlated: the returned SendHandle carries the minted tempMessageUuid, and settled() resolves with the row in chat.messages whose clientKey equals it. The optimistic row appears immediately, and the durable server echo keeps the same clientKey across the id swap.

settled() resolves with the first matching row, which is the optimistic one, so the resolved row has no cursor yet. The cursor arrives on the durable echo. Drive markRead from your chat.messages subscription, never from the settled row: markRead(message.cursor ?? "") on a settled row passes an empty cursor, which the helper drops silently.

  • The body is trimmed. An all-whitespace body sends nothing, and settled() rejects.
  • settled() rejects when core reports this send’s error before the row appears (rate limit, over-length body, session not ready, dispatch failure). Core stamps the failing send’s key on chat.error.clientKey, so the handle rejects only on its own failure. An unrelated error in the same window never rejects a delivered send. The rejection carries the chat.error text, and a repeat of the identical error still rejects.
  • secret: true sends a masked message. The raw body never enters the transcript, so no correlatable row exists and settled() never resolves. Pass timeoutMs or skip settled() for secret sends. While ui.secretMessage.isOpen is true, send with secret: true to match the composer’s mode.

submitCapture

submitCapture(value: string): CaptureHandle;

Submits the active capture field. The dataId comes from conversation.stateData.capture. Each call mints its own submitId and sends it with the event. Correlated: settled() resolves with the verdict for this submit only. An accepted value removes the capture block and resolves { ok: true }. A server validation rejection resolves { ok: false, message } with the validation error, observed on the isSubmitting falling edge. When no capture field is active, settled() rejects immediately.

Core stamps every other failure on the capture’s lastSubmitFailure, keyed with the submit’s own submitId: no session to round-trip, a value over the 500-character cap, a transport failure, a duplicate submit while one is in flight, or a host-driven reset. The handle resolves { ok: false, message } from that stamp. The handle never reads the global chat.error, so an unrelated failure in the same window cannot masquerade as this capture’s verdict. A second submitCapture sent while one is in flight settles on its own drop; it does not inherit the first submit’s verdict. On a core document that predates lastSubmitFailure, these failures are not capture-attributable, so pass timeoutMs.

submitListSelection

submitListSelection(selectedIds: string[]): void;

Submits the active selectable list. The dataId comes from conversation.stateData.listSelection. No-op when no list selection is active. Fire-and-forget.

Read and unread

markRead

markRead(cursor: string, opts?: { conversationId?: string }): void;

Advances the persisted read watermark. Call it with message.cursor for each message that scrolls into view. Do not reimplement read tracking; this helper carries the contract the reference app validates:

  • Reports are debounced 400 ms, so one scroll gesture produces one report.
  • The watermark is a monotonic lexical maximum over cursor strings. Calling with an older cursor never lowers it.
  • The accumulator and any pending report reset when chat.conversationId changes. A cursor collected in conversation A can never commit against conversation B.

One rule stays with the caller: the transcript preserves the previous conversation’s rows, so exclude messages stamped with a different conversationId before reporting their cursors. See the worked example.

Fire-and-forget. The committed watermark comes back on chat.lastReadCursor.

History

loadOlderMessages

loadOlderMessages(): void;

Requests an older page of history. No-op unless chat.canLoadMore is true and no load is already in flight. Fire-and-forget; the page arrives in chat.messages.

onHistoryLoaded

onHistoryLoaded(callback: (seq: number) => void): () => void;

Invokes callback each time restored or baseline history loads or reloads, when chat.recentMessagesLoadSeq advances. This is the one-shot trigger for “scroll to the bottom of restored history” and for seeding an unread divider. The subscription re-arms silently when the sequence resets to 0 on a chat clear. Returns an unsubscribe function.

Surveys and End Chat

checkEndChatEligibility

checkEndChatEligibility(opts?: SettleOptions): Promise<EndChatEligibilityResult>;

Asks core whether End Chat should present a survey. Correlated: the helper latches csat.endChatEligibility.seq and resolves when it advances, because the contract has no request id. Resolves with { eligible, surveyTarget }. A check core drops (a host-driven reset in progress, or a startup that cannot accept it) resolves as ineligible. Pass timeoutMs to bound the wait.

resetApp

resetApp(): void;

Resets the app frame’s conversation state. Fire-and-forget.

skipCsatAndEndChat

skipCsatAndEndChat(): void;

Ends the chat without presenting a survey. Fire-and-forget.

startNewConversation

startNewConversation(opts?: { cooldownMs?: number }): boolean;

Starts a new conversation. Applies a local cooldown (default 5 seconds) that swallows rapid re-clicks. Returns false while cooling down, without sending anything.

submitCsat

submitCsat(data: CsatSubmitData, opts: { surveyType: string; partial?: boolean; conversationId?: string | null }): CsatSubmitHandle;

Submits a CSAT survey. Correlated: settled() resolves { ok: true } when csat.submitSuccess.seq advances with a matching surveyType and conversation, and { ok: false, message } on the mirror-image error triple. Error verdicts correlate on the csat.submitError.seq advance, so a repeat of the identical error text still settles. A non-partial submit core drops resolves { ok: false, message } the same way. This includes an invalid or non-numeric score and a submit with no resolvable conversation. The helper matches conversationId only when you passed one.

Pass the right conversation scope:

  • For a survey rendered in the transcript, pass conversationId: message.conversationId. A transcript row can outlive the conversation it rates, and matching on survey type alone would let a late answer from a dead conversation settle the wrong survey.
  • For End Chat and proactive flows, omit conversationId. Core resolves the conversation itself. Passing null is the same as omitting it, on the wire and in correlation.

A partial submit (an early score tap) reports no terminal outcome, so its settled() only settles through timeoutMs.

trackCsatShown

trackCsatShown(surveyType: string, conversationId?: string | null): void;

Reports that a survey was displayed, for analytics. Fire-and-forget.

Composer

notifyComposerChanged

notifyComposerChanged(opts?: { secret?: boolean }): void;

Reports a content-free composer-changed edge, which drives live-agent typing indicators. The event carries no payload by contract: composer text never crosses the frame boundary. The helper suppresses the event entirely in secret mode, when you pass secret: true or when ui.secretMessage.isOpen is true. Even a content-free typing edge signals that a secret is being typed. Fire-and-forget.

toggleSecretMessage

toggleSecretMessage(open: boolean): void;

Opens or closes the masked (secret) composer mode. Clear your local composer text on every transition. Fire-and-forget.

Files

startFileUpload

startFileUpload(file: File): FileUploadHandle;

Starts a live-agent file upload. The helper mints an uploadId and retains the exact { file, uploadId } pair, so the handle’s retry() replays exactly it. A fresh pair would orphan the original error state in core.

Progress and errors land on file.isUploading, file.transfer, and file.uploadError. Watch both flags: core can reject a selection and report file.uploadError without ever setting file.isUploading to true. The handle’s dismissError() clears a reported error.

Settings

downloadTranscript

downloadTranscript(opts?: SettleOptions): Promise<void>;

Downloads the transcript. Correlated: resolves or rejects when transcript.download.status reaches success or error with transcript.download.status.seq advanced past the call’s baseline. A request core drops without ever broadcasting pending (a host-driven reset in progress, or an AI Agent without transcript downloads enabled) still rejects. Check config.downloadTranscriptEnabled before you show the affordance. On a core document without the seq key, the helper falls back to the pending to terminal edge. The status key is global, so treat the request as single-flight. A call made while one is in flight joins the in-flight request and settles on its verdict. Pass timeoutMs to bound the wait.

emailTranscript

emailTranscript(email: string, opts?: SettleOptions): Promise<void>;

Emails the transcript. Correlated on transcript.email.status and its seq, with the same settle contract as downloadTranscript. An AI Agent without email transcripts enabled rejects every request; check config.emailTranscriptEnabled before you show the affordance. The helper trims the email and sends the trimmed value. An empty result rejects immediately without sending, because core drops an empty email silently. The single-flight rule is address-aware. A call for the SAME address as the in-flight request joins it and settles on its verdict. A call for a different address cannot join. Core rejects only that call, matched on the address it sent. The in-flight call is not affected: its request completes, writes the final status, and settles its own handle with the true outcome. A conflict rejection never means the in-flight email was not sent. On an older core document without the conflict signal, the conflicting call settles on the in-flight verdict instead.

requestNotificationPermission

requestNotificationPermission(): void;

Asks the host page to request Web Notification permission. Permission is per-origin, so your frame’s own permission value is not the governing one. Fire-and-forget. The result arrives on this state key (the prefix is legacy naming):

1client.subscribeKey("chatter.notificationPermission", (permission) => {
2 // "default" | "granted" | "denied"
3});

setAlertSound

setAlertSound(enabled: boolean): void;

Toggles the new-message alert sound. Fire-and-forget.

setLanguage

setLanguage(language: string): void;

Changes the conversation language. Pass a BCP 47 tag the AI Agent offers. The offered set is config.clientDefaultLanguage plus config.translatedLanguages; neither list alone is complete. Fire-and-forget.

setTextSize

setTextSize(size: "small" | "default" | "large"): void;

Sets the user’s in-widget text-size override. The persisted value comes back on appearance.userTextSize. Fire-and-forget.

setTheme

setTheme(theme: "light" | "dark" | "auto"): void;

Sets the user’s in-widget theme override. The persisted value comes back on appearance.userTheme. Fire-and-forget.

Chrome

close

close(): void;

Activates the header close affordance. Ends the chat when endChat.canEnd is true. Fire-and-forget.

dismissError

dismissError(): void;

Clears chat.error in core. Always clear the error in core rather than hiding it locally: chat.error keeps its value when the session’s second identical error arrives (only chat.error.seq advances), so a locally hidden error would render nothing the second time. Fire-and-forget.

dismissToast

dismissToast(): void;

Dismisses the visible toast. Fire-and-forget.

minimize

minimize(): void;

Minimizes the widget. Fire-and-forget.

reportAppError

reportAppError(error: unknown, componentStack?: string): void;

Reports a fatal app error to core, which renders the fallback UI and records telemetry. Callable from anywhere with a client reference, including a class error boundary outside any React provider. Fire-and-forget.

retryAfterOutage

retryAfterOutage(): void;

Retries after a response outage banner. Fire-and-forget.

setHostViewportExpanded

setHostViewportExpanded(expanded: boolean): void;

Asks the SDK host to expand or restore the app’s viewport surface. Fire-and-forget.

Observe state changes

Two primitives on the client complement the full subscribe(callback) subscription. Both return an unsubscribe function. client.destroy() releases every subscription and rejects every pending settled() promise with a BridgeClientDestroyedError.

subscribeKey(key, callback, opts?) fires only when one key’s value changes:

1client.subscribeKey("chat.isGenerating", (generating, previous) => {
2 toggleTypingIndicator(generating === true);
3});

select(selector, callback, opts?) does the same for a derived projection:

1client.select(
2 (state) => state?.["chat.messages"]?.length ?? 0,
3 (count) => updateCounter(count),
4);

Options for both:

OptionDefaultPurpose
emitInitialfalseAlso invoke the callback immediately with (currentValue, undefined).
equalsObject.isChange detector that decides whether the callback fires.

Object.is is a sound default for subscribeKey. State crosses the frame boundary by structured clone, and the client restores each structurally unchanged key’s previous reference before it notifies. An object key’s identity therefore changes exactly when its content does. For chat.messages, that is every streaming delta. For select, a selector that builds a new object per call defeats Object.is: pass a custom equals, or select a primitive signature (for example, a joined string of ids). See State and events.

In React, useBridgeStateKey(key) delegates to subscribeKey and re-renders only when its key’s value changes.

Derivation helpers

The loaded module exports pure helpers ported from Ada’s reference app. They take state or messages and return values. No client is needed:

1const { filterDisplayable, groupMessages, messageKey } = bridge;

The derivation helpers are runtime-only. The npm package declares their types but ships no implementation, and createMockBridgeClient does not include them. In unit tests, inject the helpers into the code under test as plain function parameters or props. To exercise the real implementations, load the module with loadMessagingBridge() in an integration test. Do not re-implement them: a copy drifts from the runtime’s edge cases.

HelperReturns
filterDisplayable(messages, isConversationActive)The messages a transcript should render, in order. Drops non-rendering presence rows, unrenderable videos, superseded CSAT rows, and stale sign-in cancel chips. Pass chat.isConversationActive, defaulted to false. Apply your own chat.answeredInteractiveIds filtering afterwards. Then pass the result to selectUnread as its messages option.
findFirstUnread(messages, lastReadCursor)The first unread AI Agent or agent message past the read watermark, or null. Skips the user’s own messages and presence markers. Also returns null when the watermark is empty, which is the state of a user who has never had a read reported.
groupMessages(messages)Per-index { showDivider, isGroupedWithPrev } decisions: date dividers and sender-run grouping, with the exact rules the reference app renders with.
isAgentTyping(state)Whether someone is composing a reply: core’s typing verdict or the active agent’s own flag.
isConnectivityLost(state)Whether connectivity is lost, per outage.connectivityLost. This is the sanctioned signal; see the connectivity note.
isHistoricalRow(messageConversationId, state)Whether a row belongs to a conversation the end user has left. Consult it before enabling any interactive row. Unstamped rows count as current; stamped rows with no active conversation count as historical.
messageKey(message)The stable render key: streamId ?? clientKey ?? id.
resolveBotName(state)The AI Agent’s display name: config.botName, else config.handle, else "Ada".
selectUnread(state, opts?){ firstUnreadId, count } derived from the transcript and chat.lastReadCursor. Pass your filtered transcript as messages so the boundary anchors on a row you render and the count matches visible rows. The default is the raw chat.messages, which can anchor on a row filterDisplayable drops. Rows that render nothing never count. Pass boundaryId (a messageKey) to keep a shown divider anchored while newer messages arrive. An empty watermark yields count: 0, so seed first-contact users yourself; see the worked example.

Use messageKey as your render key and as your unread-boundary anchor. A raw message id changes twice: a streaming bubble swaps to its final id, and an optimistic send swaps to its durable id. Keying on the raw id replays animations, double-fires arrival effects, and breaks unread anchors.

Worked example: an unread badge

A headless integration that shows an unread count (a “red dot”) and clears it when the user opens the panel.

One trap sits in the first-contact case, which is exactly when a proactive badge matters most. The watermark starts empty for a user who has never had a read reported, and selectUnread yields count: 0 for an empty watermark. Without seeding, a proactive or agent message that arrives before the user ever opens the panel shows no badge, silently, until the first markRead commits. Seed it yourself: while chat.lastReadCursor is empty, count every non-user row as unread.

1const bridge = await loadMessagingBridge();
2const client = bridge.createBridgeClient();
3client.sendEvent("app.initialize");
4
5// 1. Keep the badge in sync. selectUnread returns a fresh object,
6// so select the primitive count. While the watermark is empty
7// (no read ever reported), count every non-user row as unread;
8// selectUnread reports 0 for an empty watermark.
9client.select(
10 (state) => {
11 if (!state?.["chat.lastReadCursor"]) {
12 return (state?.["chat.messages"] ?? []).filter(
13 (m) => m.sender !== "user" && m.type !== "presence",
14 ).length;
15 }
16 return bridge.selectUnread(state).count;
17 },
18 (count) => {
19 badge.hidden = count === 0;
20 badge.textContent = String(count);
21 },
22 { emitInitial: true },
23);
24
25// 2. While the panel is open, advance the read watermark.
26// Exclude rows stamped with a different conversation: the
27// transcript preserves them, but their cursors are foreign.
28client.subscribeKey("chat.messages", (messages) => {
29 if (!panelIsOpen) return;
30 const current = client.getState()?.["chat.conversationId"] ?? null;
31 for (const message of messages ?? []) {
32 if (message.conversationId != null && message.conversationId !== current) continue;
33 if (message.cursor) client.operations.markRead(message.cursor);
34 }
35});

The badge count falls to zero on its own: markRead commits the debounced watermark, core advances chat.lastReadCursor, and the select subscription re-derives the count. To also render an unread divider, pass your filtered transcript as messages, anchor the divider on selectUnread(state, { messages }).firstUnreadId, and keep it stable with the boundaryId option.

Types

Handle and option types ship in the npm package. Import them from @ada-cx/messaging-bridge.

BridgeClientDestroyedError

The rejection every pending settled() promise receives when client.destroy() runs. The npm package exports the type only. Detect it by error.name === "BridgeClientDestroyedError", never by instanceof: the class value lives in the CDN runtime.

CaptureHandle

{ settled(opts?: SettleOptions): Promise<OperationResult> }

Returned by submitCapture.

CsatSubmitHandle

{ settled(opts?: SettleOptions): Promise<OperationResult> }

Returned by submitCsat.

EndChatEligibilityResult

1{ eligible: boolean | null; surveyTarget: "bot" | "agent" | null }

Resolved by checkEndChatEligibility. surveyTarget names which survey End Chat should present.

FileUploadHandle

{ uploadId: string; retry(): void; dismissError(): void }

Returned by startFileUpload.

ObserveOptions

{ equals?: (a: T, b: T) => boolean; emitInitial?: boolean }

Options for subscribeKey and select.

OperationResult

{ ok: true } | { ok: false; message: string }

The outcome of a correlated submit.

SendHandle

{ tempMessageUuid: string; settled(opts?: SettleOptions): Promise<Message> }

Returned by sendMessage. tempMessageUuid is the uuid put on the wire; the sent message is findable in chat.messages by clientKey === tempMessageUuid.

SettleOptions

{ timeoutMs?: number }

Accepted by every settled() method, by checkEndChatEligibility, and by the transcript helpers. When set, the promise rejects with an Error after timeoutMs milliseconds without an answer.