# @moveo-ai/web-client

> Client side library to load the moveo chat widget and connect it with your agent

Latest version **0.132.0** (published 2026-10-21) · MIT license · 0 weekly downloads

## Install

```sh
npm install @moveo-ai/web-client
pnpm add @moveo-ai/web-client
yarn add @moveo-ai/web-client
bun add @moveo-ai/web-client
```

## Health

**Score 65/100 (B)** — status: active.

Positive: has types; no vulnerabilities; recently updated; high maintenance score; high quality score.

Warnings: low downloads; no esm support; pre 1.0.

## Facts

| | |
|---|---|
| Version | 0.132.0 |
| Published | 2026-10-21 |
| First published | 2022-01-03 |
| Weekly downloads | 0 |
| License | MIT |
| TypeScript types | bundled |
| Module format | CommonJS |
| Node | >=20 |
| Dependencies | 0 |
| Unpacked size | 6 MB |
| Known vulnerabilities | 0 |
| Install scripts | no |
| Author | Moveo.ai |
| Maintainers | baspavlou, germanattanasio, anavalo |
| Keywords | bot, moveo.ai, agent, assistant, chatbot |

## Links

- npm: https://www.npmjs.com/package/@moveo-ai/web-client
- Repository: https://github.com/moveo-ai/web-client
- Homepage: https://moveo.ai
- Issues: https://github.com/moveo-ai/web-client/issues
- npm.io page: https://npm.io/package/@moveo-ai/web-client

## Recent versions

- 0.132.0 (latest) — 2026-10-21
- 0.127.0-true.25 (next) — 2026-10-22
- 0.127.0-true.24 — 2026-10-21
- 0.127.0-true.23 — 2026-10-21
- 0.127.0-true.22 — 2026-10-21
- 0.127.0-true.21 — 2026-10-21
- 0.127.0-true.20 — 2026-10-20
- 0.127.0-true.19 — 2026-10-20
- 0.127.0-true.18 — 2026-10-20
- 0.127.0-true.17 — 2026-10-19
- 0.131.0 — 2026-10-19
- 0.127.0-true.16 — 2026-10-19
- 0.127.0-true.15 — 2026-10-19
- 0.130.0 — 2026-10-16
- 0.127.0-true.14 — 2026-10-16
- … 651 more at https://npm.io/package/@moveo-ai/web-client/versions

## README

# @moveo-ai/web-client

[![npm version](https://img.shields.io/npm/v/@moveo-ai/web-client.svg)](https://www.npmjs.com/package/@moveo-ai/web-client)
[![npm downloads](https://img.shields.io/npm/dm/@moveo-ai/web-client.svg)](https://www.npmjs.com/package/@moveo-ai/web-client)
[![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](https://opensource.org/licenses/MIT)

Embeddable chat widget for integrating [Moveo.ai](https://moveo.ai) AI Agents into your website or application.

![](https://user-images.githubusercontent.com/52110045/147710079-c7da74c3-de6a-47e4-9ef8-83cadd4b1f5f.png)

## Features

![](https://user-images.githubusercontent.com/52110045/147710060-7ed0eed1-df5e-4f51-ba14-f3fa040a7a7f.png)

- Real-time chat with AI Agents
- Customizable appearance and theming
- Multi-language support (26 locales)
- File upload and media sharing
- Mobile-responsive design
- Secure iframe-based isolation

## Table of Contents

- [Quick Start](#quick-start)
- [Installation](#installation)
- [Usage](#usage)
- [API Reference](#api-reference)
- [Configuration](#configuration)
- [TypeScript Support](#typescript-support)
- [Browser Support](#browser-support)
- [Contributing](#contributing)
- [License](#license)

## Quick Start

1. Create an account at [console.moveo.ai](https://console.moveo.ai)
2. Create an environment and web integration
3. Copy your integration ID
4. Add the widget to your website using one of the methods below

## Installation

### CDN (Recommended for most websites)

Add the script tag to your HTML:

```html
<script src="https://web.moveo.ai/web-client.min.js"></script>
<script>
  MoveoAI.init({ integrationId: 'YOUR_INTEGRATION_ID' });
</script>
```

### npm (For module bundlers)

```bash
npm install @moveo-ai/web-client
```

```typescript
import { init } from '@moveo-ai/web-client';

init({ integrationId: 'YOUR_INTEGRATION_ID' });
```

**Requirements:** Node.js 20+

## Usage

### Basic

```html
<!DOCTYPE html>
<html>
  <head>
    <title>My Website</title>
  </head>
  <body>
    <!-- Your website content -->

    <script src="https://web.moveo.ai/web-client.min.js"></script>
    <script>
      window.addEventListener('load', function () {
        MoveoAI.init({ integrationId: 'YOUR_INTEGRATION_ID' });
      });
    </script>
  </body>
</html>
```

### Programmatic Control

`init` resolves to a `WidgetController` once the widget is ready:

```javascript
MoveoAI.init({ integrationId: 'YOUR_INTEGRATION_ID' }).then((controller) => {
  // Open the chat window
  controller.openWindow();

  // Close the chat window
  controller.closeWindow();

  // Send a message programmatically
  controller.sendMessage({ text: 'Hello!' });

  // Run a dialog node directly, with no text
  controller.sendMessage({ trigger_node_id: 'YOUR_NODE_ID' });

  // Update the conversation context
  controller.updateContext({ custom_field: 'custom_value' });

  // Destroy the widget
  controller.destroy();
});
```

### More examples

<details>
<summary>Check analytics events during development</summary>

Log every event the widget tracks to verify your integration before wiring it
into an analytics tool:

```javascript
MoveoAI.init({ integrationId: 'YOUR_INTEGRATION_ID' }).then((controller) => {
  controller.onAnalyticsEvent(
    ({ timestamp, event, session_id, properties }) => {
      console.debug('[moveo]', timestamp, event, session_id, properties);
    }
  );
});
```

</details>

<details>
<summary>Identify a logged-in user and open the chat</summary>

```javascript
MoveoAI.init({ integrationId: 'YOUR_INTEGRATION_ID' }).then((controller) => {
  controller.updateContext({
    user: { display_name: 'Jane Doe', email: 'jane@example.com' },
    tags: ['premium'],
    plan: 'business', // any other key becomes a context variable
  });

  document.querySelector('#help-button').addEventListener('click', () => {
    controller.openWindow();
  });
});
```

</details>

<details>
<summary>Mirror the unread indicator in your own UI</summary>

```javascript
MoveoAI.init({ integrationId: 'YOUR_INTEGRATION_ID' }).then((controller) => {
  const badge = document.querySelector('#chat-badge');

  controller.onUnreadMessages(() => badge.classList.add('visible'));
  controller.onNoUnreadMessages(() => badge.classList.remove('visible'));
});
```

</details>

## API Reference

### MoveoAI.init(config)

Initializes the chat widget.

| Parameter            | Type                                        | Required | Description                                                   |
| -------------------- | ------------------------------------------- | -------- | ------------------------------------------------------------- |
| `integrationId`      | `string`                                    | Yes      | Your Moveo.ai integration ID                                  |
| `host`               | `string`                                    | No       | Regional channels host, when it differs from the default      |
| `element`            | `HTMLElement \| string`                     | No       | Embed target, required for the `embed` widget position        |
| `identityToken`      | `string`                                    | No       | Signed token that verifies the user's identity on the session |
| `voiceIntegrationId` | `string`                                    | No       | Deprecated and ignored: the call uses the desk voice pairing  |
| `variant`            | `'default' \| 'nbg' \| 'alpha' \| 'fiserv'` | No       | Visual variant                                                |

Any other key passes through as a widget-config override for the loaded integration.

**Returns:** `Promise<WidgetController>` - resolves once the widget is ready

### WidgetController Methods

| Method                        | Description                                                  |
| ----------------------------- | ------------------------------------------------------------ |
| `openWindow()`                | Opens the chat window                                        |
| `closeWindow()`               | Closes the chat window                                       |
| `closeConversation({ hide })` | Ends the conversation; the widget hides unless `hide: false` |
| `sendMessage({ text })`       | Sends a message to the agent                                 |
| `sendMessage({ trigger_node_id })` | Runs a dialog node directly. Send it without text |
| `showTeaserMessage(message?)` | Shows the teaser bubble                                      |
| `updateContext(ctx)`          | Updates the conversation context                             |
| `setCSSVariables(vars)`       | Overrides widget CSS variables                               |
| `setLocale(locale)`           | Switches the widget language                                 |
| `destroy()`                   | Removes the widget from the page                             |
| `setVisitorConsent(granted)`  | Passes the visitor's cookie consent for the visitor id       |

The widget reads or writes the `moveo_visitor_id` cookie only after the visitor
acts in the widget: opens it, taps a teaser or a reply, or types. A panel that
opens by itself does not count. The cookie is set on your registrable domain,
so `www` and other subdomains share one id. If the page runs
OneTrust, the widget follows the visitor's choice there: the category your
OneTrust lists the cookie in, or Performance (`C0002`) if it is not listed. If
the page runs Cookiebot, the widget follows the Statistics category.
`setVisitorConsent(granted)` wins over all of them. After `setVisitorConsent(true)`,
the widget writes the cookie at once, without a visitor action. After `setVisitorConsent(false)`,
the widget never reads or writes the cookie, and removes one that is already
there. In the EU, the cookie needs the visitor's consent. If your site uses
another consent tool, call `setVisitorConsent` from it.

`updateContext` calls made inside a 300 ms window merge into one update, so a
burst of calls costs one round trip and the first call still ships at once.
Arrays such as `tags` replace the stored value rather than append, so
`tags: []` clears them. A call made before a conversation exists is held and
sent once the session opens. `closeConversation` sends any update still queued
before it ends the session.

### WidgetController Events

Register a callback per event. Every callback returns nothing and receives the
payload listed below.

| Method                     | Fires when                                     | Payload                                         |
| -------------------------- | ---------------------------------------------- | ----------------------------------------------- |
| `onSessionCreated(cb)`     | A new session opens                            | `{ user_id, session_id, display_name }`         |
| `onSessionReconnected(cb)` | An existing session reconnects                 | `{ user_id, session_id, display_name }`         |
| `onSessionClosed(cb)`      | The session closes                             | `{ restarting: true }` on a restart, else none  |
| `onConversationClosed(cb)` | The user ends the conversation                 | —                                               |
| `onWebchatOpened(cb)`      | The user opens the widget                      | `{ isFull, is_mobile? }`                        |
| `onWebchatClosed(cb)`      | The user closes the widget                     | `{ isFull }`                                    |
| `onLinkClicked(cb)`        | The user clicks a link inside the chat         | `{ url }`                                       |
| `onUnreadMessages(cb)`     | Unread messages exist while the widget is shut | —                                               |
| `onNoUnreadMessages(cb)`   | The unread indicator clears                    | —                                               |
| `onUserActivity(cb)`       | The visitor interacts with the widget          | —                                               |
| `onMemberLeave(cb)`        | An agent leaves the conversation               | —                                               |
| `onAnalyticsEvent(cb)`     | The widget loads, and every interaction        | `{ timestamp, event, session_id?, properties }` |

`onAnalyticsEvent` receives every interaction the widget tracks, identified by
the `event` field: `session_start`, `session_end`, `message_sent` (the user sent
a message — never carries the text), `launcher_clicked`, `teaser_clicked`
(the visitor tapped the teaser line, which opens the chat),
`teaser_quick_reply_clicked` (a teaser reply the visitor tapped, carrying its
configured `label`), `teaser_composer_submitted` (the visitor sent their own
line from the teaser — carries no properties, because the text is theirs),
`section_not_found` (a `teaser.pages[].sections[]` entry whose strongest
selector no longer matches the page — see "A different teaser per section"),
`rating_submitted`, and the rest of the `AnalyticsEvent` enum in
`src/hooks/useAnalytics.ts`.

No event carries message text. `properties` is forwarded verbatim by
integrations to their own analytics vendors, so anything the visitor typed stays
out of it — only copy the integration itself configured appears there.

It also receives one event that is not an interaction: **`widget_loaded`**,
raised as soon as the widget renders on the page. It carries no `session_id`,
because a session opens only when the visitor opens the chat. Register the
callback at any time — a callback added after the widget loaded still receives
`widget_loaded` on registration, so the event cannot be missed in the race
between `init()` resolving and your `.then()` running.

#### How the header controls end a conversation

Two header controls can end a conversation, and they report differently.

**The close button (X)** asks first. Once the conversation has messages, it
opens a confirmation offering **End conversation** and **Minimize**. Ending
runs the end-of-chat survey when the account has one. On an empty chat, or
while a prompt or webview owns the panel, it just hides the panel. Set
`close_minimizes_window: true` and the button always minimizes and never ends.

**New conversation**, in the header title menu, does not ask. It ends the
conversation and opens the next one in place, with the panel still open, and
shows no end-of-chat survey.

During a voice call the close button asks first, then ends the call and hides
the panel. The conversation stays open, and the call's turns stay in the thread.

Which event to count:

| Event                               | Fires when                                                 | Means the conversation ended |
| ----------------------------------- | ---------------------------------------------------------- | ---------------------------- |
| `close_icon_clicked`                | The visitor presses X, before they answer the confirmation | No                           |
| `popover_end_chat_clicked`          | They confirm **End conversation**                          | Yes                          |
| `popover_end_chat_continue_clicked` | They pick **Minimize** instead                             | No                           |
| `restart_conversation_clicked`      | They pick **New conversation** from the menu               | Yes                          |

Add `popover_end_chat_clicked` and `restart_conversation_clicked` for the
number of conversations visitors ended themselves. `close_icon_clicked` counts
presses, not conversations: count it as an end and you overstate your totals.
`onSessionClosed` covers the visitor-confirmed end, and `onConversationClosed`
covers a close that came from the agent side, so neither alone is a total. A
restart fires `onSessionClosed` with `{ restarting: true }`, immediately
followed by `onSessionCreated` for the conversation that replaces it; count it
as an end, and ignore it if you only want the ends that left no conversation
behind. A session that timed out fires neither: nothing ended it, so neither
button on the expired prompt reports a close — **Start new chat** opens the
replacement, and **Close** puts the panel away, both in silence after the
`onSessionExpired` you already heard.

### Forward widget events into your analytics

The widget never writes into your analytics tools. If you want chat activity in
them, forward the events above from your own page — you control what is sent
and where. With Quantum Metric, for example:

```javascript
MoveoAI.init({ integrationId: 'YOUR_INTEGRATION_ID' }).then((controller) => {
  // Event ids must exist in your Quantum Metric configuration first:
  // sendEvent() silently drops ids that are not defined for the page.
  const QM_MOVEO_LOADED = 900; // example ids — define your own
  const QM_MOVEO_SESSION = 901;
  const QM_MOVEO_MESSAGE = 902;

  controller.onAnalyticsEvent(({ event }) => {
    if (event === 'widget_loaded') {
      // Fires on every page that carries the widget, with no visitor action.
      window.QuantumMetricAPI?.sendEvent(QM_MOVEO_LOADED, 0, '');
    }
  });

  controller.onSessionCreated(({ session_id }) => {
    // The Moveo session id becomes searchable on the QM session,
    // joining every replay to its Moveo conversation.
    window.QuantumMetricAPI?.sendEvent(QM_MOVEO_SESSION, 0, session_id);
  });

  controller.onAnalyticsEvent(({ event, session_id }) => {
    if (event === 'message_sent') {
      window.QuantumMetricAPI?.sendEvent(QM_MOVEO_MESSAGE, 0, session_id);
    }
  });
});
```

The same pattern works for Tealium (`utag.link(...)`), Google Tag Manager
(`dataLayer.push(...)`), or any library already on your page. Note that Quantum
Metric rate-limits `sendEvent` to 50 calls per 200 ms.

## Configuration

### Context Variables

Pass custom data to your AI Agent:

```javascript
MoveoAI.init({
  integrationId: 'YOUR_INTEGRATION_ID',
  context: {
    // User information
    user_id: 'user-123',
    user_name: 'John Doe',
    user_email: 'john@example.com',

    // Custom business data
    subscription_tier: 'premium',
    account_balance: 150.0,

    // Any custom key-value pairs
    custom_field: 'custom_value',
  },
});
```

### Teaser

The teaser is the invitation shown beside the closed launcher. `variant` picks
its shape, and each variant declares only the fields it renders, so a
placeholder on a variant with no input is a compile error rather than a silent
no-op.

| Variant          | Needs           | Renders                                                             |
| ---------------- | --------------- | ------------------------------------------------------------------- |
| `callout`        | `message`       | The line in the bubble. The default, and the teaser as it is today. |
| `question_chips` | `quick_replies` | Tappable questions **in place of** the line, at most three.         |
| `composer`       | `message`       | The line with an input under it.                                    |
| `none`           | —               | Launcher only.                                                      |

```javascript
MoveoAI.init({
  integrationId: 'YOUR_INTEGRATION_ID',
  teaser: {
    variant: 'question_chips',
    quick_replies: ['Compare products', 'Help me choose'],
  },
});

MoveoAI.init({
  integrationId: 'YOUR_INTEGRATION_ID',
  teaser: {
    variant: 'composer',
    message: 'Tell me what you sell and I will narrow it down.',
    placeholder: 'e.g. a two-till bakery',
  },
});
```

With no `variant`, replies mean `question_chips` and their absence means
`callout`, so the shorthand `{ quick_replies: [...] }` keeps working.

#### A different teaser per page

`teaser.pages[]` varies the teaser by page. Of the entries whose `url` appears
in the page's **path**, the **longest** wins, so order does not matter and
`/flex-pocket` beats `/flex`. A hint is matched against the path alone, so a
domain or a query parameter is not a page hint; the `teaser` block itself covers
everywhere else.

```javascript
MoveoAI.init({
  integrationId: 'YOUR_INTEGRATION_ID',
  teaser: {
    // The block is the teaser wherever no `pages[]` entry matches.
    variant: 'callout',
    message: 'Anything I can help with?',
    pages: [
      {
        url: '/products/flex',
        variant: 'question_chips',
        quick_replies: ['Explain Flex to me', 'Compare Flex and Station Duo'],
      },
      {
        url: '/shop',
        variant: 'composer',
        message: 'Nine systems here. Tell me what you sell.',
        placeholder: 'e.g. a two-till bakery',
      },
    ],
  },
});
```

`url` is **a hint, not a pattern**: it matches when it appears anywhere in the
page's URL, compared case-insensitively. Nothing is escaped and nothing is
compiled, so a stray `.` or `(` cannot change what matches or throw. This is the
one thing `pages[]` does differently from the `page_triggers` it replaces, whose
keys were compiled with `new RegExp`.

`pages[]` is an array and not a map keyed by URL so that precedence is visible
to whoever wrote it. `variant: 'none'` on the block, with a `pages[]` entry that
opts back in, gives a teaser on those pages and nowhere else.

**It follows the visitor.** `clientUrl` is read once when the widget loads, so on
a single-page app it names the page the visitor arrived on for the rest of the
visit. When `pages[]` is configured the loader watches for navigation and relays
the new URL, and the teaser re-resolves. An integration that configures no
`pages[]` is not watched at all, because the watcher patches `history` and never
restores it.

#### A different teaser per section of a page

On a long page one teaser is one teaser for fourteen blocks. `sections[]`, nested
in a `pages[]` entry, gives each part of the page its own, and the teaser
follows what the visitor is reading.

```javascript
MoveoAI.init({
  integrationId: 'YOUR_INTEGRATION_ID',
  teaser: {
    quick_replies: ['Compare products', 'Help me choose'],
    pages: [
      {
        url: '/shop',
        id: 'shop',
        quick_replies: ['Which Clover system fits my business?'],
        sections: [
          {
            id: 'station_duo',
            match: [
              '[data-entry-id="3UlHnDQQTGvNwHKQnXgVTz"]',
              'article > section:nth-of-type(6)',
            ],
            quick_replies: [
              'Is Station Duo worth $1,899?',
              'Station Duo or Flex?',
            ],
          },
          {
            id: 'go',
            match: ['[data-entry-id="5oA3X1sZOG6FGGUrk39jiR"]'],
            quick_replies: ['Does Go work without a counter?'],
          },
          { id: 'legal', match: ['#legal'], variant: 'none' },
        ],
      },
    ],
  },
});
```

**Resolution is section → page → block.** The section in view wins; no section
in view falls back to the page's teaser; no page match falls back to the block.
A section set to `variant: 'none'` goes quiet and does **not** fall back — it is
an instruction, not an absence.

**`match` is an ordered list of CSS selectors, strongest first**, and nothing
else: the loader runs `querySelector` on each until one matches, and observes
that element. A selector matching several elements resolves to the first in
document order. Put a stable `id` or a CMS data attribute first and a positional
selector like `article > section:nth-of-type(6)` last, so a section survives
losing its best anchor. Hashed CSS-module class names change on every deploy;
never use one.

**`id` is authored, not derived from position.** It names the section in
analytics, so an index that shifted whenever someone reordered the list would
break the history. Unlike `pages[]`, the order of `sections[]` decides nothing:
sections are chosen at runtime by what is on screen.

**The section with the largest visible share of the viewport wins**, not the
one with the largest visible ratio of itself — a 992px section 40% in view
fills more screen than a 373px one fully in view. A tie keeps the current
section, so a slow scroll cannot flap between two sets of chips, and a section
must hold the viewport for a moment before the teaser follows it, so one flick
of the wheel does not fire three swaps. The swap lands while the teaser is out
of the way for the scroll, and the new chips are what fades back in.

**`show_after` gates the first show only.** A later swap never waits — a visitor
scanning a long page would otherwise restart the delay at every block and see
nothing. **A new section re-arms a teaser that `hide_after` retired**, with a
fresh budget. **A teaser the visitor closed stays closed** for the visit,
whatever section they reach.

**Opening the chat does not spend the invitation; answering it does.** The
teaser steps aside while the panel is up, and a visitor who opens it, reads
whatever the bot opened with and closes it again has said nothing — so the
teaser comes back, waiting out `show_after` once more. It goes away for good
the moment the visitor sends anything of their own, a tapped chip included. On
a sticky session the reload waits for the earlier conversation to load before
the teaser shows, so a visitor who already talked never sees it flash.

**Sections belong to their page.** A single-page-app navigation rebuilds the
watch against the new page's sections, and clears the current section so
nothing from the previous page survives the transition. A page with no
`sections[]`, or an empty list, is not observed at all, and an integration
with no sections anywhere registers no observer.

**When a selector stops matching**, the widget emits the `section_not_found`
analytics event once per section per page view, carrying `section_id`,
`page_id`, `page_url`, `matched_index` and `selector_count`. It fires whenever
the strongest selector no longer holds: `matched_index` names the fallback that
still does, so the section can be regenerated before it breaks, and is `null`
once nothing matches. A section with no match is simply left out of the
running; the page teaser covers it.

**Chips replace the line, they do not sit under it.** Asking the question and
offering the answers says the same thing twice, and a bubble above
content-width chips leaves a ragged edge.

**Chips carry no dismiss.** `callout` and `composer` keep theirs, because both
cover part of the page. A chip is cheap to ignore, and a close control in the
stack reads as a fourth, broken chip. `close_opening_message_click` therefore
does not fire for `question_chips`.

**A variant degrades rather than rendering an empty surface.**
`question_chips` with no replies falls back to `callout`; `callout` or
`composer` with no line resolves to `none`.

#### Sending

A tapped chip and a typed composer line take the same path: the widget opens
and the text is sent as the visitor's first message, once the session is ready
and the visitor form, if any, is done. It replaces the configured
`welcome_trigger_message` for that conversation, so the visitor's own words
open it.

#### While the visitor is reading

A configured teaser steps out of the way as the host page scrolls, and fades
back a second after it stops. It is out of reach while it is out of sight, so a
keyboard cannot land on a chip nobody can see.

This applies only to an integration that sets a `teaser`. An integration with
nothing but a `welcome_message` keeps the opening message it has always had,
sitting still, and no scroll listener is attached to its pages.

#### When it appears, and when it gives up

Two optional clocks, both **off unless set**, so an integration that configures
neither behaves exactly as it does today — the teaser appears as soon as it has
something to say, and stays.

```javascript
MoveoAI.init({
  integrationId: 'YOUR_INTEGRATION_ID',
  teaser: {
    message: 'Anything I can help with?',
    show_after: 8, // wait 8s before inviting
    hide_after: 45, // retire after 45s on screen
  },
});
```

| Field        | Unit    | Off | Clamped to | Means                                                 |
| ------------ | ------- | --- | ---------- | ----------------------------------------------------- |
| `show_after` | seconds | `0` | 1–60       | Wait this long before the teaser appears.             |
| `hide_after` | seconds | `0` | 5–300      | Retire it after this long, for the rest of the visit. |

Both are **block-level**, not per-page: they describe the visit, not the page. A
`pages[]` entry with its own delay would restart the clock on every navigation
of a single-page app, so the teaser would arrive later on each page and, past a
few, never at all.

`show_after` counts **settled** seconds, and any scroll starts it over: the
invitation waits for a calm moment rather than arriving in the middle of a
gesture. The trade is that a visitor who scrolls more often than the delay never
settles for long enough and never sees the teaser at all — pick the number
against how the page is actually read, because a long delay on a page people
scan continuously invites nobody.

It is counted from the moment the teaser has something to say, not from the page
loading — the config arrives over postMessage, so a clock started earlier would
come out shortest on exactly the slow pages a delay matters on. A
`showTeaserMessage()` call is never held behind it: the host asking for the
teaser means now.

`hide_after` counts the time the teaser is **on screen**. The clock pauses while
the visitor scrolls it out of the way and resumes with what is left, so a
visitor who spent the budget scrolling has not spent it ignoring the teaser.
Once retired, the teaser is gone for the visit; the launcher is not.

A malformed value — negative, non-numeric, infinite — reads as off rather than
being clamped up to the minimum, so a bad config cannot start hiding a teaser
the integration can see today.

#### The line

Precedence, highest first:

1. A line handed to **`showTeaserMessage('...')`**, for as long as it is set.
   Calling it with no argument clears the override and hands the teaser back to
   the config.
2. **`page_triggers[].opening_message`** for this URL.
3. **`teaser.message`** (or the `pages[]` entry's).
4. The deprecated top-level **`welcome_message`**.

`message` supersedes `welcome_message`, which is still read when `message` is
absent. The two legacy sources above it stay ahead of `teaser.message` on
purpose: per-page copy an integration already relies on, or a line the host
asked for by name, must not be replaced by a default someone adds later.

`page_triggers` is **deprecated and frozen**. It still varies the line and the
auto-sent opener by URL for the five integrations that set it, and it is not
removed, because one of them is a live customer. New work belongs on
`teaser.pages[]`.

`manual_teaser_mode` and `showTeaserMessage()` keep their meaning.

#### Restyling it

The teaser follows the accent out of the box. The bubble reads
`--color-opening-message-background` and
`--color-opening-message-foreground`, and the chips and the composer field
read the `--color-options-*` set. All five are in
[CSS Variables](#css-variables), and are set through `setCSSVariables()`.

### Inactivity Timeout

The widget opens an inactivity modal once the visitor goes quiet, and asks
whether to keep the conversation. A voice call counts as activity. Set `inactivity_timeout` in minutes to change
when it appears. Set it in the integration's configuration, or override it on
`init()`:

```javascript
MoveoAI.init({
  integrationId: 'YOUR_INTEGRATION_ID',
  inactivity_timeout: 20, // minutes; 0 turns the modal off
});
```

| Value                             | Effect                                     |
| --------------------------------- | ------------------------------------------ |
| `0`                               | No modal. The widget still tracks activity |
| `3` to `30`                       | The modal opens after that many minutes    |
| Below `3`, or above `30`          | Clamped into the 3 to 30 minute range      |
| Absent, negative, or not a number | The default, 10 minutes                    |

Below 3 minutes the modal interrupts a visitor who only stopped to read. Above
30 it holds a dead session open for hours. A negative value falls back to the
default rather than to `0`, so a malformed configuration cannot switch the
modal off without anyone noticing.

Only activity inside the widget counts. A visitor who scrolls your page but
does not touch the chat is inactive. A page load counts as activity, so a
visitor who comes back to your site starts the countdown again.

### CSS Variables

Pass any of these to `setCSSVariables()` to restyle the widget. Every value is
a plain CSS value, applied inside the widget iframe.

```javascript
const widget = await MoveoAI.init({ integrationId: 'YOUR_INTEGRATION_ID' });

widget.setCSSVariables({
  '--moveo-border-radius-surface': '8px',
  '--moveo-font-primary': "'Roboto', sans-serif",
});
```

| Variable                        | Default                      | Controls                                                       |
| ------------------------------- | ---------------------------- | -------------------------------------------------------------- |
| `--moveo-font-primary`          | unset, see below             | The widget font family                                         |
| `--moveo-border-radius-surface` | `16px`                       | Corner radius of the chat panel, dialogs and the webview sheet |
| `--moveo-border-radius-base`    | `5px`                        | Corner radius of inline media and the launcher teaser pill     |
| `--moveo-header-height`         | `68px`                       | Header height, and the offset every screen below it starts at  |
| `--moveo-header-background`     | from the theme               | Header background; `theme_colors.header_color` sets it too     |
| `--moveo-header-text-color`     | from the theme               | Header text and icon colour                                    |
| `--moveo-shadow-bubble`         | `0px 0px 10px 0px #10182824` | Launcher bubble shadow                                         |
| `--moveo-loadingIcon-size`      | `1rem`                       | Size of the loading spinner                                    |
| `--moveo-modal-tall-height`     | `min(600px, 100%)`           | Height of a `tall` webview dialog; never exceeds the widget    |
| `--moveo-modal-tall-width`      | `min(400px, 100%)`           | Width of a `tall` webview dialog; never exceeds the widget     |
| `--moveo-expanded-chat-width`   | `672px`                      | Panel width in the expanded layout                             |
| `--moveo-chat-width`            | from the config              | Panel width; the `chatWindow` config sets it                   |
| `--moveo-chat-height`           | from the config              | Panel height; the `chatWindow` config sets it                  |
| `--moveo-z-index`               | `10022`                      | Stacking of the widget against your page. See below            |

#### Fonts

`--moveo-font-primary` is unset by default. The widget then uses the font its
configuration loads — the `font_family` setting, or the font that ships with
your variant. Set the variable to override that, at any time and from either
`setCSSVariables()` or your own stylesheet. The widget never writes it back, so
your value survives a font that finishes loading later.

#### Stacking order

The widget renders in an iframe on your page, so its stacking is decided by
your document, not by anything inside the widget. Raise it above a header or a
cookie banner either from your own stylesheet:

```css
:root {
  --moveo-z-index: 999999;
}
```

or through the API, which applies it to the iframe on your page:

```javascript
widget.setCSSVariables({ '--moveo-z-index': '999999' });
```

Every other variable in the table applies inside the widget iframe.

`--nbg-bg` also exists, for one account's variant styling. It is not part of
the supported surface.

#### Colours

Set colours in the integration's `theme_colors` config, which carries a `light`
and a `dark` set. Each key writes the CSS variables listed below, so the same
colours can be overridden at runtime through `setCSSVariables()`, which wins
over the config. `border_color` is the one key that writes two: control
boundaries need their own token to stay above the 3:1 WCAG 1.4.11 asks of them,
while dividers and card edges stay lighter.

| `theme_colors` key     | CSS variable                        | Controls                                                    |
| ---------------------- | ----------------------------------- | ----------------------------------------------------------- |
| `accent_color`         | `--color-accent`                    | The accent, and the default for every colour marked below   |
| `background_color`     | `--color-background`                | The widget surface, and the area behind the messages        |
| `border_color`         | `--color-border`                    | Panel, composer and card borders                            |
| `border_color`         | `--color-stroke-input`              | Text fields, selects and checkboxes                         |
| `header_color`         | `--moveo-header-background`         | Header background                                           |
| `text_color_header`    | `--moveo-header-text-color`         | Header text and icons                                       |
| `text_color_user`      | `--color-text-primary-foreground`   | Text inside the visitor's own bubbles                       |
| `text_color_agent`     | `--color-text-secondary-foreground` | Text inside the agent's bubbles                             |
| `launcher_background`  | `--color-launcher-background`       | Launcher bubble; defaults to the accent                     |
| `launcher_foreground`  | `--color-launcher-foreground`       | Launcher icon; defaults to the widget background            |
| `link_color`           | `--color-link`                      | Links in messages; defaults to the accent                   |
| `rating_background`    | `--color-rating-background`         | Selected rating button; defaults to the accent              |
| `send_icon_color`      | `--color-send-icon`                 | Send icon in the composer; defaults to the accent           |
| `swimmlane_background` | `--color-swimmlane-background`      | System message pill background                              |
| `swimmlane_foreground` | `--color-swimmlane-foreground`      | System message pill text; defaults to the agent text colour |

A colour that says "defaults to" follows `--color-accent` — or the colour named
— until you set it. So an accent alone restyles the links, the rating, the send
icon and the launcher, and `--color-chat-background` follows the background.

Six more colours have no `theme_colors` key, so they are set through
`setCSSVariables()` only:

| Variable                             | Controls                                                                                                               |
| ------------------------------------ | ---------------------------------------------------------------------------------------------------------------------- |
| `--color-chat-background`            | The area behind the messages; follows the background                                                                   |
| `--color-options-background`         | Quick reply buttons, and the teaser chips and composer field                                                           |
| `--color-options-foreground`         | Quick reply labels, the teaser composer text, and a chip under the pointer or the focus ring; defaults to the accent   |
| `--color-options-border`             | Quick reply borders, the teaser composer field, and a chip under the pointer or the focus ring; defaults to the accent |
| `--color-opening-message-background` | The teaser bubble, on `callout` and `composer`; defaults to the accent                                                 |
| `--color-opening-message-foreground` | The teaser bubble text; defaults to the visitor's own bubble text colour                                               |

The two `opening-message` names are the teaser's, from before it was called
one. They are the only lever that moves the bubble off the accent, so an
integration whose accent is too dark to invite anybody sets them here.

#### Renamed variables

`--moveo-new-header-height` and `--moveo-new-border-radius-base` are the former
names of `--moveo-header-height` and `--moveo-border-radius-surface`. Setting
either still works, so existing integrations need no change, but use the names
in the table above.

### Supported Languages

The widget supports 26 locales including: English, Spanish, French, German, Italian, Portuguese, Dutch, Greek, Arabic, Hebrew, and more.

```javascript
MoveoAI.init({
  integrationId: 'YOUR_INTEGRATION_ID',
  language: 'es', // Spanish
});
```

## TypeScript Support

The package ships type declarations for the documented API surface
(`web-client.d.ts`):

```typescript
import { init, UserConfig, WidgetController } from '@moveo-ai/web-client';

const config: UserConfig = { integrationId: 'YOUR_INTEGRATION_ID' };
const controller: WidgetController = await init(config);

controller.onSessionCreated(({ session_id }) => {
  console.log('session', session_id);
});
```

## Browser Support

The widget ships ES2020 and targets the browsers resolved from
`.browserslistrc` (`defaults`, excluding IE 11, dead browsers, Opera Mini and
KaiOS 2.5). In practice:

- Chrome, Edge, Firefox, Safari: current and recent versions
- iOS Safari and Android Chrome: current and recent versions
- Opera Mini and KaiOS 2.5 are not supported; they cannot run the bundle

Run `npx browserslist` to print the exact resolved list for the current
`caniuse-lite` data.

## Contributing

Development setup, commands, architecture notes and the full development
documentation index live in
[CONTRIBUTING.md](https://github.com/moveo-ai/web-client/blob/main/CONTRIBUTING.md)
in the GitHub repository.

## Support

- Documentation: [docs.moveo.ai](https://docs.moveo.ai)
- Console: [console.moveo.ai](https://console.moveo.ai)
- Website: [moveo.ai](https://moveo.ai)

## License

MIT License - see [LICENSE](LICENSE) for details.

---
_Source: https://npm.io/package/@moveo-ai/web-client · Machine-readable twin of the npm.io package page. Health data is recomputed on every publish._
