npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@moveo-ai/web-client

v0.117.0

Published

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

Readme

@moveo-ai/web-client

npm version npm downloads License: MIT

Embeddable chat widget for integrating Moveo.ai AI Agents into your website or application.

Features

  • 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

  1. Create an account at 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:

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

npm (For module bundlers)

npm install @moveo-ai/web-client
import { init } from '@moveo-ai/web-client';

init({ integrationId: 'YOUR_INTEGRATION_ID' });

Requirements: Node.js 20+

Usage

Basic

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

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!' });

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

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

More examples

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

MoveoAI.init({ integrationId: 'YOUR_INTEGRATION_ID' }).then((controller) => {
  controller.onAnalyticsEvent(
    ({ timestamp, event, session_id, properties }) => {
      console.debug('[moveo]', timestamp, event, session_id, properties);
    }
  );
});
MoveoAI.init({ integrationId: 'YOUR_INTEGRATION_ID' }).then((controller) => {
  controller.updateContext({
    user: { display_name: 'Jane Doe', email: '[email protected]' },
    tags: ['premium'],
    plan: 'business', // any other key becomes a context variable
  });

  document.querySelector('#help-button').addEventListener('click', () => {
    controller.openWindow();
  });
});
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'));
});

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 | Voice pairing override for the in-widget call | | 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 | | 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 |

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

Restart, 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 ends the call, after its own confirmation.

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

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:

MoveoAI.init({
  integrationId: 'YOUR_INTEGRATION_ID',
  context: {
    // User information
    user_id: 'user-123',
    user_name: 'John Doe',
    user_email: '[email protected]',

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

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.

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.

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 that reload does not tell us anything until they open the chat, so a returning visitor sees the teaser again and loses it as soon as their earlier conversation loads.

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.

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, 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. Set inactivity_timeout in minutes to change when it appears. Set it in the integration's configuration, or override it on init():

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.

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:

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

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

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.

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

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 in the GitHub repository.

Support

License

MIT License - see LICENSE for details.