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.
<!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
| Attribute | Required | Default | Description |
|---|---|---|---|
data-public-key | yes* | — | Your pk_ publishable key (public, safe in HTML). |
data-tenant-token | yes* | — | Alternative to data-public-key — a host-minted JWT (see below). |
data-api-base | no | https://api.monoverse.tech | Set 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.
| Attribute | Default | Description |
|---|---|---|
data-lang | auto | Force the assistant language (e.g. uk, ru). Omit to let the backend pick. |
data-position | bottom-right | Launcher corner — bottom-right or bottom-left. |
data-primary-color | #0f76ff | Accent colour of the launcher and widget chrome (any CSS colour). |
data-z-index | 999999 | Stacking order of the widget host, if it clashes with your own fixed UI. |
data-eager | false | true connects the WebSocket on load instead of on first interaction (pre-warm). |
data-prewarm | false | true (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-pages | — | Comma-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>
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.
- Voice
- Chat
<script
src="https://api.monoverse.tech/widget/widget-voice.js"
data-public-key="pk_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
async
></script>
<script
src="https://api.monoverse.tech/widget/widget-chat.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 adestroy().window.VoiceBot.version— the loaded bundle version.
setHandlers is the actions tier, not the embedThe 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 HS256tenant_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-tokenis present the widget takes the host-minted path; otherwise it usesdata-public-keyand 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
| Symptom | Cause | Fix |
|---|---|---|
Widget never appears; console public-token exchange failed (403) | current origin not on the pk_ allow-list | add the exact origin (with www/subdomain/port) to the key's allow-list |
400 origin_required | request 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 origin | pk_ is wrong or revoked | re-issue / verify the pk_ |
Widget mounts but the WebSocket closes 4403 | token claim origin ≠ real browser origin | the origin in the allow-list and in the browser must match exactly |