npm.io
0.126.0 • Published 2d ago

@moveo-ai/web-client

Licence
MIT
Version
0.126.0
Deps
0
Size
5.8 MB
Vulns
0
Weekly
0

@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

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

  // 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
Check analytics events during development

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);
    }
  );
});
Identify a logged-in user and open the chat
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();
  });
});
Mirror the unread indicator in your own UI
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 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:

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

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

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.

Keywords