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

# Operations

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

`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](/messaging/custom-apps/state-and-events) for the underlying contract.

```ts
const handle = client.operations.sendMessage("Hello");
const message = await handle.settled({ timeoutMs: 10_000 });

// settled() resolves with the optimistic row, which carries no cursor
// yet. Report reads from a chat.messages subscription instead, over
// the rows that carry one:
client.subscribeKey("chat.messages", (messages) => {
  for (const row of messages ?? []) {
    if (row.cursor) client.operations.markRead(row.cursor);
  }
});
```

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

> **Note**
>
> 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](#derivation-helpers).

## 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`](#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`](#derivation-helpers) and `chat.answeredInteractiveIds`. Answering a row from a previous conversation sends its target into the current one.

The `chat.answeredInteractiveIds` set can shrink when core withdraws a failed programmatic answer. Read the set on every update. Do not retain an answered state after the set removes its ID.

#### sendMessage

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

Sends a chat message. Correlated: the returned [`SendHandle`](#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`](#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](#worked-example-an-unread-badge).

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

The helper sends `chat.logs.loadMore`. After a failed page, Core ignores requests during a delay for that conversation without setting `chat.loadingPreviousMessages`. The delay starts at 1 second and doubles after each failure, up to 30 seconds.

Retry on a new end-user scroll, not when the loading flag clears. The optional [`chat.previousMessagesLoadSeq`](/messaging/custom-apps/state-and-events#conversation-keys) counter counts successful older-history pages, including pages with no visible rows. When the counter rises, you can request the next page if `chat.canLoadMore` is true.

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

#### endLiveChat

`endLiveChat(): void;`

Ends an active or pending human-agent handoff while keeping the conversation open. Fire-and-forget.
For integrations configured to return to the AI Agent, omit the End Chat survey. Render any supplied post-agent survey.
Core ignores the call when no handoff is active or pending.

#### resetApp

`resetApp(): void;`

Clears the transcript and starts a new conversation. Fire-and-forget.

If proactive conversation requests are in progress, both restart operations wait for them. If any request succeeds, Core satisfies the restart through that proactive conversation. Its messages and the existing transcript remain, and Core sends no extra greeting.

One restart can also use a completed success before its conversation appears. After that restart finishes, another explicit restart is a new request. This lets an end user retry when no conversation arrives. With no pending request or unused success awaiting conversation delivery, each operation starts its normal reset immediately.

Core discards a restart when a newer conversation, session, or accepted user send replaces its original context. If all requests fail and that context remains current, Core runs the latest accepted restart once. `resetApp()` clears the transcript, while `startNewConversation()` preserves it. An accepted user send or retry cancels a waiting restart before it can erase the newer message. A rejected send leaves the restart intact.

While [`app.conversationStartPending`](/messaging/custom-apps/state-and-events#conversation-keys) is `true`, render both restart controls as busy. Keep the transcript visible. Core clears the state when it finishes or discards the request. Treat an absent key on an older Core as `false`. `resetApp()` remains fire-and-forget and still sends if called while pending.

#### skipCsatAndEndChat

`skipCsatAndEndChat(): void;`

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

For integrations configured to return to the AI Agent after live chat ends, wait until `liveAgent.liveChatEndedInConversation` becomes true. Then call `skipCsatAndEndChat()` directly from the next End Chat control. Skip confirmation, eligibility checks, and the End Chat survey only in this case. Otherwise, use the normal confirmation and survey eligibility checks.

#### startNewConversation

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

Starts a new conversation while preserving prior messages. Applies a local cooldown (default 5 seconds) that swallows rapid re-clicks. Returns `false` while cooling down or while `app.conversationStartPending` is `true`, without sending anything. A pending refusal does not extend the cooldown. A `true` result means the request was sent, not that the conversation has started.

Proactive conversation requests follow the shared restart behavior described under [resetApp](#resetapp).

#### submitCsat

`submitCsat(data: CsatSubmitData, opts: { conversationId?: string | null; endConversation?: "inline" | "endChat"; partial?: boolean; submit?: boolean; surveyType: string; uiComplete?: boolean }): CsatSubmitHandle;`

Submits a CSAT survey. `settled()` resolves `{ ok: true }` when `csat.submitSuccess.seq` advances with a matching `surveyType` and conversation. It resolves `{ ok: false, message }` when the matching `csat.submitError.seq` advances with non-null error text, including repeated errors with identical text. Core reports invalid scores and missing conversations when `uiComplete` is true. Router gates report dropped submits when `uiComplete` is true and `partial` is false. The helper matches `conversationId` only when you pass one.

The helper accepts these options:

* `endConversation` defaults to `"endChat"` for `surveyType: "end_chat"`; otherwise, the helper omits it. The value reports which survey surface completed. After a successful write with `uiComplete: true`, core ends the current surveyed conversation only for `answer_flow` and `end_chat`. `"endChat"` uses End Chat behavior. `"inline"` omits `ada:close_chat`.

  Other survey types, including `live_chat`, keep the conversation open. Neither value ends a newer conversation that replaced the surveyed conversation.
* `submit` defaults to `!partial || surveyType === "end_chat"`, except that a proactive partial write omits it. Explicit values override this default. A successful non-partial POST with `submit: true` publishes `ada:csat_submitted`. Partial POSTs emit no event, including `end_chat` writes. This flag does not complete the survey UI or end the conversation by itself.
* `uiComplete` defaults to `!partial`. When true, core marks a successful write submitted and advances `csat.submitSuccess.seq`. When false, the write reports no UI completion and does not end the conversation. A terminal failure requires `uiComplete: true` without `inlineAnswerId`. Only terminal failures set `csat.submitError`, advance its sequence, and set both scope keys. Only a valid terminal write clears the error and both scope keys.

  Silent failures preserve all four values. `settled()` resolves a failure only when the matching terminal error sequence advances.

When `uiComplete` is true, core cancels queued autosaves for the same conversation and survey type. All writes for one conversation share a queue, across survey types and conversation generations. Each survey type keeps its own accumulated answers. An in-flight request completes or reaches its own timeout before the next write starts. A generation change discards only unsent autosaves. Canceled autosaves emit no host event or completion result.

Each autosave POST has its own 20-second timeout from request start. A terminal Submit with `uiComplete: true` has one 20-second deadline from receipt of `csat.submit` in core. This deadline includes queue wait and request time. If the deadline expires in the queue, core reports the timeout and never posts that terminal write.

A queued terminal write retains its captured conversation after a conversation change. It still posts unless core is destroyed or its deadline expires. Completion results identify the submitted survey and conversation, including after a conversation change. Core changes global survey flags or ends the conversation only if the conversation generation remains current.

If a raw `sendEvent("csat.submit", ...)` omits both `submit` and `uiComplete`, core applies the same defaults as the helper. This includes the proactive partial exception and `endConversation: "endChat"` for `end_chat`. An explicit `endConversation` overrides that default.

To match legacy inline and post-agent counting, send every first answer with `submit: true` and omit `partial`.
Use `uiComplete: false` for post-agent first answers and inline surveys with follow-ups. For an inline survey without follow-ups, use `uiComplete: true` without `endConversation`.
Follow-ups exist when feedback chips, resolution, open feedback, CES, or NPS is enabled, regardless of question order.
This includes surveys with satisfaction disabled and only one enabled question.
For inline first answers, use raw `sendEvent("csat.submit", ...)` with a unique `inlineAnswerId`.
Each successful write counts, emits `ada:csat_submitted`, and keeps the conversation open.

1. Send scores immediately. Open the full form when follow-ups are enabled.
2. Debounce only the saves for comments, chips, resolution, CES, and NPS for 1000 ms.
3. Open End Chat and post-agent surveys directly in the full form when open feedback is first.
4. On Close or Dismiss, send a pending first answer immediately. On unmount or a fatal handoff, discard it.
5. Match the first answer's `inlineAnswerId` against the `csat.inlineSubmitErrorIds` list. It holds the 200 most recent failed IDs, independent of conversation and survey type. Only that failure resets the inline answer and pending full-survey cache. Show no error text, so the end user can tap again to retry.

A change to a pending first answer replaces its delayed write. Do not also send a follow-up save for that change.
Before a follow-up save, disable first-answer rollback. A failed follow-up save keeps the modal and all answers intact.

For End Chat and follow-up forms, save selections only when no enabled question is required. Send scores with `submit: true, uiComplete: false` and omit `partial`. For resolution, CES, and NPS, use raw `sendEvent("csat.submit", ...)` with `uiComplete: false` and omit both flags. Keep comments and chips local until **Submit**. For the End Chat Submit button, send `submit: true, uiComplete: true, endConversation: "endChat"`. This write also counts and ends the current surveyed conversation after success.

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.

`partial` defaults to false. Setting it to true sends the legacy partial flag and defaults `uiComplete` to false. For `end_chat`, `submit` still defaults to true. For proactive surveys, the helper omits `submit` and core omits `partial` from the POST. For other survey types, `submit` defaults to false. Explicit `submit` and `uiComplete` values override those defaults.

With `uiComplete: false`, `settled()` receives no completion result from that write. Use `timeoutMs` to bound the wait. Do not use `partial: true` for a first answer that must count as a completed response.

#### 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):

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

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

#### setFrameStyles

`setFrameStyles(styles: CustomAppFrameStyles): void;`

Sets the position, size, and appearance of your custom app's outer frame. Returns no result.
Frame styles apply to desktop floating layouts only; in other layouts the frames fill their region.
Style your app's contents with your own CSS.
To cover the whole screen for a moment, for example for an image viewer, use [`setHostViewportExpanded`](#sethostviewportexpanded). It locks page scrolling and restores your layout when it ends.

All properties are optional. Numbers use CSS pixels. Percentages use the host viewport.

| Property          | Accepted values                                                                                                    |
| ----------------- | ------------------------------------------------------------------------------------------------------------------ |
| `backgroundColor` | `#rgb`, `#rgba`, `#rrggbb`, `#rrggbbaa`, or `"transparent"`.                                                       |
| `borderColor`     | `#rgb`, `#rgba`, `#rrggbb`, `#rrggbbaa`, or `"transparent"`. Requires `borderWidth`.                               |
| `borderRadius`    | Number from 0 to 2048.                                                                                             |
| `borderWidth`     | Number from 0 to 32. The border is solid. Without `borderColor`, the border is transparent.                        |
| `bottom`          | Number from -4096 to 4096, percentage from `"-100%"` to `"100%"`, or `"auto"`.                                     |
| `boxShadow`       | `"none"`, or one shadow with optional `inset`, two to four pixel lengths, and a hex color. Maximum 256 characters. |
| `height`          | Number from 0 to 4096 or percentage from `"0%"` to `"100%"`.                                                       |
| `left`            | Number from -4096 to 4096, percentage from `"-100%"` to `"100%"`, or `"auto"`.                                     |
| `maxHeight`       | Number from 0 to 4096 or percentage from `"0%"` to `"100%"`.                                                       |
| `maxWidth`        | Number from 0 to 4096 or percentage from `"0%"` to `"100%"`.                                                       |
| `minHeight`       | Number from 0 to 4096 or percentage from `"0%"` to `"100%"`.                                                       |
| `minWidth`        | Number from 0 to 4096 or percentage from `"0%"` to `"100%"`.                                                       |
| `right`           | Number from -4096 to 4096, percentage from `"-100%"` to `"100%"`, or `"auto"`.                                     |
| `top`             | Number from -4096 to 4096, percentage from `"-100%"` to `"100%"`, or `"auto"`.                                     |
| `width`           | Number from 0 to 4096 or percentage from `"0%"` to `"100%"`.                                                       |

Numbers must be finite. Percentage strings accept decimal digits, an optional minus sign, and no spaces, such as `"60%"` or `"12.5%"`.
For shadows, each length must stay between -256 and 256 pixels. The third length, the blur radius, cannot be negative.
Zero can omit `px`. Other shadow lengths require `px`.
Unknown properties, invalid values, CSS functions, and multiple shadows reject the whole call. The previous styles stay.

Center a modal frame with a translucent background:

```ts
if (typeof client.operations?.setFrameStyles === "function") {
  client.operations.setFrameStyles({
    top: 0,
    right: 0,
    bottom: 0,
    left: 0,
    width: 640,
    height: 480,
    backgroundColor: "#00000066",
    borderRadius: 16,
  });
}
```

Use a wider drawer:

```ts
client.operations?.setFrameStyles?.({ width: 520 });
```

Each call replaces the entire request. Omitted properties return to defaults. Call `client.operations.setFrameStyles({})` to restore all defaults.
The default frame has 24-pixel bottom and side gaps, with limits of 375 pixels wide and 720 pixels high.
See [Frame layout](/messaging/web/sdk-api-reference#frame-layout) for the default CSS dimensions.
When you supply dimensions, limits, or insets, omitted width and height values use 375px and 720px.
Set all four insets to `0` to center a frame with a fixed width and height. Dimensions include the border.
While the open frame covers the full viewport, the SDK locks page scrolling. Smaller frames do not lock page scrolling.

Without a custom `width`, `height`, `maxWidth`, or `maxHeight`, the default maximum dimensions remain 48 pixels below the viewport dimensions.
Setting any of those properties changes the default maximum dimensions to the full viewport. Your `maxWidth` and `maxHeight` values replace those limits.
[`setHostViewportExpanded(true)`](#sethostviewportexpanded) takes precedence until you pass `false`.

Styles survive close and reopen, [`reset()`](/messaging/web/sdk-api-reference#reset), and [`deleteHistory()`](/messaging/web/sdk-api-reference#deletehistory).
If reset or history deletion remounts your app document, the styles stay. The new document can replace or clear them.
A widget reload or unrecoverable error clears the request. Fallback to the default app, `destroy()`, and `stop()` also clear it.

Read [`appearance.hostViewport`](/messaging/custom-apps/state-and-events#configuration-and-preference-keys) to choose sizes for different host viewports.
See the [transparency and security notes](/messaging/custom-apps/overview#frame-size-and-position) before using a large or transparent frame.
Older hosts ignore the request. Check that the operation exists before calling it, as the first example shows.

#### setHostViewportExpanded

`setHostViewportExpanded(expanded: boolean): void;`

Makes the frame cover the full browser viewport. Use it for content that needs more room than the drawer, such as an image viewer.
Pass `false` to return the frame to its configured size and position.

* While the frame is expanded, the SDK locks page scrolling.
* It works only in floating mode. In `parentElement`, headless and the mobile SDKs, the call does nothing.
* It applies only while the drawer is open. The SDK remembers the request across close and reopen.
* While it is active, it overrides [`setFrameStyles`](#setframestyles). Your latest frame styles apply when you pass `false`.
* The SDK releases it on reset, history deletion, destroy, and timeout fallback.
* The call returns nothing and confirms nothing.

To change the frame's standing size or position, use [`setFrameStyles`](#setframestyles).

## 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:

```ts
client.subscribeKey("chat.isGenerating", (generating, previous) => {
  toggleTypingIndicator(generating === true);
});
```

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

```ts
client.select(
  (state) => state?.["chat.messages"]?.length ?? 0,
  (count) => updateCounter(count),
);
```

Options for both:

| Option        | Default     | Purpose                                                                |
| ------------- | ----------- | ---------------------------------------------------------------------- |
| `emitInitial` | `false`     | Also invoke the callback immediately with `(currentValue, undefined)`. |
| `equals`      | `Object.is` | Change 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](/messaging/custom-apps/state-and-events#update-cadence).

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:

```ts
const { filterDisplayable, groupMessages, messageKey } = bridge;
```

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

| Helper                                              | Returns                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                  |
| --------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `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](/messaging/custom-apps/state-and-events#connectivity).                                                                                                                                                                                                                                                                                                                                                                                                           |
| `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 name to attribute a message to: `config.botName`, else `config.handle`, else `"Ada"`. Use it for sender labels and for screen reader announcements. Do not use it to name the AI Agent in your own UI. A handle is an internal identifier. Use `resolveDisplayBotName` for that.                                                                                                                                                                                                                                                                                                     |
| `resolveDisplayBotName(state)`                      | The name to show as the AI Agent's own identity: `config.botName`, else empty. Use it for a header title, a greeting heading, or any place your UI names the AI Agent. It returns an empty string when the customer configured no name. Ada's own widget shows no name in that case. It never falls back to the handle. This helper is absent on core builds published before it existed, so guard the call: `bridge.resolveDisplayBotName?.(state) ?? ""`. Your npm version does not choose the runtime build.                                                                          |
| `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](#worked-example-an-unread-badge). |

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.

```ts
const bridge = await loadMessagingBridge();
const client = bridge.createBridgeClient();
client.sendEvent("app.initialize");

// 1. Keep the badge in sync. selectUnread returns a fresh object,
//    so select the primitive count. While the watermark is empty
//    (no read ever reported), count every non-user row as unread;
//    selectUnread reports 0 for an empty watermark.
client.select(
  (state) => {
    if (!state?.["chat.lastReadCursor"]) {
      return (state?.["chat.messages"] ?? []).filter(
        (m) => m.sender !== "user" && m.type !== "presence",
      ).length;
    }
    return bridge.selectUnread(state).count;
  },
  (count) => {
    badge.hidden = count === 0;
    badge.textContent = String(count);
  },
  { emitInitial: true },
);

// 2. While the panel is open, advance the read watermark.
//    Exclude rows stamped with a different conversation: the
//    transcript preserves them, but their cursors are foreign.
client.subscribeKey("chat.messages", (messages) => {
  if (!panelIsOpen) return;
  const current = client.getState()?.["chat.conversationId"] ?? null;
  for (const message of messages ?? []) {
    if (message.conversationId != null && message.conversationId !== current) continue;
    if (message.cursor) client.operations.markRead(message.cursor);
  }
});
```

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`](#submitcapture).

#### CsatSubmitHandle

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

Returned by [`submitCsat`](#submitcsat).

#### CustomAppFrameStyles

All properties are optional. Each [`setFrameStyles`](#setframestyles) call replaces the previous request.
See the operation's value table for the accepted properties and limits.

#### EndChatEligibilityResult

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

Resolved by [`checkEndChatEligibility`](#checkendchateligibility). `surveyTarget` names which survey End Chat should present.

#### FileUploadHandle

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

Returned by [`startFileUpload`](#startfileupload).

#### ObserveOptions

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

Options for [`subscribeKey` and `select`](#observe-state-changes).

#### OperationResult

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

The outcome of a correlated submit.

#### SendHandle

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

Returned by [`sendMessage`](#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.