@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
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
- Installation
- Usage
- API Reference
- Configuration
- TypeScript Support
- Browser Support
- Contributing
- License
Quick Start
- Create an account at console.moveo.ai
- Create an environment and web integration
- Copy your integration ID
- 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-clientimport { 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:
- 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. page_triggers[].opening_messagefor this URL.teaser.message(or thepages[]entry's).- 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
- Documentation: docs.moveo.ai
- Console: console.moveo.ai
- Website: moveo.ai
License
MIT License - see LICENSE for details.
