Skip to main content
Version: next

Installation

The one-tag embed

Put this anywhere in <body> (or in <head> with async). Replace pk_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx with the key issued for this site's origin.

index.html
<!doctype html>
<html lang="uk">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Example store</title>
</head>
<body>
<h1>Example store</h1>

<script
src="https://api.monoverse.tech/widget/widget-voice.js"
data-public-key="pk_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
async
></script>
</body>
</html>

The assistant button appears once the script loads. The widget renders into its own Shadow DOM host, so it never collides with your page's styles.

Attributes

AttributeRequiredDefaultDescription
data-public-keyyes*Your pk_ publishable key (public, safe in HTML).
data-tenant-tokenyes*Alternative to data-public-key — a host-minted JWT (see below).
data-api-basenohttps://api.monoverse.techSet only if you self-host the API.

* Exactly one of data-public-key or data-tenant-token must be present on the tag.

The widget loader defaults the audio worklet to {api-base}/pcm-worklet-processor.js, which the backend serves at the root — you do not set a worklet URL.

Appearance & behaviour (optional)

The launcher's position, colour, and connect timing are configurable from the tag. All are optional — the defaults below match the deployed widget.

AttributeDefaultDescription
data-langautoForce the assistant language (e.g. uk, ru). Omit to let the backend pick.
data-positionbottom-rightLauncher corner — bottom-right or bottom-left.
data-primary-color#0f76ffAccent colour of the launcher and widget chrome (any CSS colour).
data-z-index999999Stacking order of the widget host, if it clashes with your own fixed UI.
data-eagerfalsetrue connects the WebSocket on load instead of on first interaction (pre-warm).
data-prewarmfalsetrue (or 1) opens the WebSocket session on the launcher's hover/focus (pointerenter / focus), so the handshake is already in flight by the click. A hover that never becomes a click still creates a conversation on the backend — that is why it is opt-in. data-eager is the stronger variant.
data-exclude-pagesComma-separated path prefixes where the widget must not mount (e.g. /checkout).
<script
src="https://api.monoverse.tech/widget/widget-voice.js"
data-public-key="pk_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
data-lang="uk"
data-position="bottom-left"
data-primary-color="#16a34a"
async
></script>
Connection hints are automatic

Independently of data-prewarm, the widget always injects <link rel="preconnect"> + <link rel="dns-prefetch"> hints for the API/WS origins on boot (skipped for same-origin and on excluded pages), so the first token exchange and WebSocket handshake start on a warm connection. No attribute needed.

Voice or chat

The bundle name in the src picks the variant. Swap the file; everything else is identical.

<script
src="https://api.monoverse.tech/widget/widget-voice.js"
data-public-key="pk_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
async
></script>

The window.VoiceBot global

Once the bundle loads it exposes a small global for imperative control:

interface VoiceBotGlobal {
version: string;
init: () => void;
open: () => void;
close: () => void;
toggle: () => void;
destroy: () => void;
setHandlers: (handlers: unknown) => void;
}
  • window.VoiceBot.open() / close() / toggle() — open, close, or toggle the widget panel programmatically (e.g. from your own "Talk to us" button).
  • window.VoiceBot.destroy() — tear down the widget instance (closes the WebSocket, releases the microphone). Call this if you remove the widget from a single-page app.
  • window.VoiceBot.init() — re-initialise after a destroy().
  • window.VoiceBot.version — the loaded bundle version.
setHandlers is the actions tier, not the embed

The plain script tag is a read-only consultant grounded in your catalog. To let the bot act on the page (add to cart, pick a variant, apply a filter) you wire the host bridge — set window.VoiceBotSyncBridge (or call window.VoiceBot.setHandlers(...)) before the widget boots. That is the separate, heavier acting path (ADR-053). See Capabilities & host actions, Custom site integration, and What the embed supports.

SPA / route changes / re-mount

The widget is a singleton with a re-entrancy guard: re-evaluating the script, or an inject → remove → inject cycle in a single-page app, never leaves two WebSockets or two microphones open — the previous controller receives destroy() before a new mount. If you tear down a view that held the widget, call window.VoiceBot?.destroy?.() explicitly to close the WebSocket and audio immediately.

Alternative: host-minted JWT (data-tenant-token)

If you already run a backend and prefer to mint the session token yourself (as the WordPress plugin does), use data-tenant-token instead of data-public-key:

<script
src="https://api.monoverse.tech/widget/widget-voice.js"
data-tenant-token="<origin-locked JWT issued by your backend>"
async
></script>
  • Your backend makes a server-to-server HMAC request to POST /api/v1/widget/issue-token (body {site_url, lang}) and receives an origin-locked HS256 tenant_token.
  • Use this when you already have a backend and don't want to expose the mint in the browser, need a custom TTL/claims flow, or integrate through a ready-made plugin.
  • The two attributes are mutually exclusive on one tag: if data-tenant-token is present the widget takes the host-minted path; otherwise it uses data-public-key and self-mints from the browser.

The data-public-key path is the default for self-built sites with no backend; the host-minted JWT path is the same byte-for-byte flow the WordPress plugin uses.

Run the example locally

The reference page is examples/embed/vanilla/index.html. Serve it with any static server, e.g. npx serve ., then open the page (its origin must be on the key's allow-list — register http://localhost:3000 or whichever port you serve on).

Troubleshooting

SymptomCauseFix
Widget never appears; console public-token exchange failed (403)current origin not on the pk_ allow-listadd the exact origin (with www/subdomain/port) to the key's allow-list
400 origin_requiredrequest had no Origin (non-browser context)load the embed in a normal browser context
public-token exchange failed (429)rate limit (per-IP or per-pk_)wait for Retry-After; do not re-inject the tag in a loop
403 with a known originpk_ is wrong or revokedre-issue / verify the pk_
Widget mounts but the WebSocket closes 4403token claim origin ≠ real browser originthe origin in the allow-list and in the browser must match exactly