Custom site integration
This page is for custom storefronts — not WooCommerce. If your site runs on a custom Laravel, Node, or Python backend with a React, Next.js, Vue, or vanilla JS frontend, this is your integration map.
The architecture has two independent halves:
-
GRANT (server-side) — your backend tells VoiceBot what your host can do by pushing a
host_profileingest entity. Without it the bot only has the read baseline (catalog search, product lookup, navigation). Cart, variant, and filter actions require an explicit grant. -
EXECUTE (client-side) — your frontend implements
window.VoiceBotSyncBridge(or uses an SDK wrapper) so the widget can call your cart/variant/filter APIs when the bot invokes those actions.
Both halves must be wired. A host_profile grant without a bridge means the bot offers actions but
cannot execute them. A bridge without a host_profile means the bot never offers those actions.
npx @monoverse/voicebot-scaffold generates the bridge adapter for your framework (React, Next.js,
Vue, or vanilla) wired to the SDK helpers (useCartBridge / window.VoiceBotSyncBridge) — you fill in
your cart store and grant the matching capabilities below.
Stack matrix
Pick your backend and frontend combination below.
- Laravel
- Node.js
- Python
Step 1 — Grant capabilities via host_profile (Laravel)
Enable the host_profile entity in config/voicebot.php and list the capabilities your store
supports beyond the catalog/nav baseline:
use Monoverse\VoicebotSync\Dto\EntityKind;
'entities' => [
EntityKind::HostProfile->value => [
'enabled' => true,
'source' => null,
'capabilities' => [
'cart.add',
'cart.remove',
'cart.update_qty',
'cart.view',
'variant.select',
'nav.search_page',
],
'cart_endpoint' => null,
'metadata' => [],
],
// … your product / category / page entities …
],
Run a sync to push the host_profile entity:
php artisan voicebot:sync --full
The bot now has add_to_cart, remove_from_cart, update_cart_qty, view_cart, and
select_variant tools enabled for your tenant.
Capability reference:
| Capability | What it enables |
|---|---|
cart.add | add_to_cart tool |
cart.remove | remove_from_cart tool |
cart.update_qty | update_cart_qty tool |
cart.view | view_cart tool |
variant.select | select_variant tool |
nav.search_page | open_search_with_query / search tools |
checkout.assist | checkout field-fill tools (form-only, no auto-pay) |
form.submit | per-form submit tools |
The catalog/nav baseline (catalog.search, catalog.get_product, catalog.compare,
catalog.facets, nav.open) is always available once any catalog is ingested — no declaration
needed.
Step 1 — Grant capabilities via host_profile (Node.js)
Push a host_profile entity alongside your catalog with the @monoverse/voicebot-node client.
Include it as a raw entity in your snapshot generator:
import { VoiceBotClient } from '@monoverse/voicebot-node';
const client = new VoiceBotClient({ /* … */ });
async function* entities() {
// your catalog entities …
yield {
kind: 'host_profile',
external_id: 'host',
payload: {
capabilities: [
'cart.add',
'cart.remove',
'cart.update_qty',
'cart.view',
'variant.select',
],
},
};
}
await client.snapshot(entities());
Step 1 — Grant capabilities via host_profile (Python)
Push a host_profile entity directly via the ingest HTTP API. The monoverse-voicebot Python SDK
does not yet expose a typed host_profile builder — send it as a raw NDJSON record in your snapshot
body or as an event:
{
"kind": "host_profile",
"external_id": "host",
"payload": {
"capabilities": [
"cart.add",
"cart.remove",
"cart.update_qty",
"cart.view",
"variant.select",
"nav.search_page"
]
}
}
Or include it in a snapshot stream alongside your products and categories. See the Ingest Protocol Reference for the snapshot endpoint and signing requirements.
- React
- Next.js
- Vue
- Vanilla JS
Step 2 — Wire the bridge (React)
Use <VoiceBotWidget> from @monoverse/voicebot-react and pass onAction + capabilities:
import { VoiceBotWidget } from '@monoverse/voicebot-react';
import { cart, pdp, productList } from './store';
export function Layout({ children }: { children: React.ReactNode }) {
return (
<>
{children}
<VoiceBotWidget
publicKey="pk_…"
capabilities={[
'cart.add', 'cart.remove', 'cart.update_qty', 'cart.view',
'variant.select', 'catalog.facets',
]}
onAction={{
addToCart: ({ product_external_id, variation_external_id, quantity }) =>
cart.add(product_external_id, variation_external_id, quantity),
removeFromCart: ({ product_external_id }) =>
cart.remove(product_external_id),
updateCartQty: ({ product_external_id, quantity }) =>
cart.setQty(product_external_id, quantity),
viewCart: ({ fallback_url }) =>
cart.open() || window.location.assign(fallback_url ?? '/cart'),
selectVariant: ({ product_external_id, axis_values }) => {
pdp.applyVariant(product_external_id, axis_values);
return { status: 'executed_ok' };
},
applyFilter: ({ taxonomy, value, mode }) =>
productList.filter(taxonomy, value, mode),
}}
/>
</>
);
}
Step 2 — Wire the bridge (Next.js)
Create a Client Component for the widget (bridge handlers run in the browser):
'use client';
import { VoiceBotWidget } from '@monoverse/voicebot-next';
import { cart, pdp, productList } from '@/store';
export function VoiceBotProvider() {
return (
<VoiceBotWidget
publicKey="pk_…"
capabilities={[
'cart.add', 'cart.remove', 'cart.update_qty', 'cart.view',
'variant.select', 'catalog.facets',
]}
onAction={{
addToCart: ({ product_external_id, variation_external_id, quantity }) =>
cart.add(product_external_id, variation_external_id, quantity),
removeFromCart: ({ product_external_id }) =>
cart.remove(product_external_id),
updateCartQty: ({ product_external_id, quantity }) =>
cart.setQty(product_external_id, quantity),
viewCart: ({ fallback_url }) =>
cart.open() || window.location.assign(fallback_url ?? '/cart'),
selectVariant: ({ product_external_id, axis_values }) => {
pdp.applyVariant(product_external_id, axis_values);
return { status: 'executed_ok' };
},
applyFilter: ({ taxonomy, value, mode }) =>
productList.filter(taxonomy, value, mode),
}}
/>
);
}
Import it into your root layout:
import { VoiceBotProvider } from '@/components/VoiceBotProvider';
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html>
<body>
{children}
<VoiceBotProvider />
</body>
</html>
);
}
Step 2 — Wire the bridge (Vue)
Use <VoiceBotWidget> from @monoverse/voicebot-vue and pass onAction + capabilities:
<script setup lang="ts">
import { VoiceBotWidget } from '@monoverse/voicebot-vue';
import { cart, pdp, productList } from './store';
const onAction = {
addToCart: ({ product_external_id, variation_external_id, quantity }) =>
cart.add(product_external_id, variation_external_id, quantity),
removeFromCart: ({ product_external_id }) =>
cart.remove(product_external_id),
updateCartQty: ({ product_external_id, quantity }) =>
cart.setQty(product_external_id, quantity),
viewCart: ({ fallback_url }) =>
cart.open() || window.location.assign(fallback_url ?? '/cart'),
selectVariant: ({ product_external_id, axis_values }) => {
pdp.applyVariant(product_external_id, axis_values);
return { status: 'executed_ok' };
},
applyFilter: ({ taxonomy, value, mode }) =>
productList.filter(taxonomy, value, mode),
};
</script>
<template>
<RouterView />
<VoiceBotWidget
public-key="pk_…"
:capabilities="[
'cart.add', 'cart.remove', 'cart.update_qty', 'cart.view',
'variant.select', 'catalog.facets',
]"
:on-action="onAction"
/>
</template>
Step 2 — Wire the bridge (Vanilla JS)
Set window.VoiceBotSyncBridge before the widget script tag so it is ready when the bundle boots:
<script>
window.VoiceBotSyncBridge = {
protocolVersion: 1,
capabilities: [
'cart.add', 'cart.remove', 'cart.update_qty', 'cart.view',
'variant.select', 'catalog.facets',
],
cart: {
add: function(args) {
return myCart.add(args.product_external_id, args.variation_external_id, args.quantity);
},
remove: function(args) { return myCart.remove(args.product_external_id); },
updateQty: function(args) { return myCart.setQty(args.product_external_id, args.quantity); },
view: function(args) { return myCart.open() || window.location.assign(args.fallback_url ?? '/cart'); },
},
selectVariant: function(args) {
pdp.applyVariant(args.product_external_id, args.axis_values);
return { status: 'executed_ok' };
},
applyFilter: function(args) {
return productList.filter(args.taxonomy, args.value, args.mode);
},
};
</script>
<script
src="https://api.monoverse.tech/widget/widget-voice.js"
data-public-key="pk_…"
async
></script>
Host cart handlers — runtime action names
The cart capabilities map to three host handlers. The SDK wrappers (@monoverse/voicebot-{react,vue,next})
and the typed window.VoiceBotSyncBridge.cart.* fields use the friendly names in the examples above
(addToCart / removeFromCart / updateCartQty, or cart.add / cart.remove / cart.updateQty) and
map them onto the runtime action for you. If you instead implement the low-level runtime dispatch
(a raw toolHandlers map, a mobile native callback, or your own bridge router), you must key off the
exact runtime action name the backend dispatches — which is not always the model-facing tool name.
| Capability | Model tool name | Runtime action (host handler key) | Typed bridge field | Params your handler receives |
|---|---|---|---|---|
cart.add | add_to_cart | add_to_cart | cart.add | { product_external_id, variation_external_id?, quantity } |
cart.remove | remove_from_cart | remove_from_cart | cart.remove | { product_external_id } |
cart.update_qty | update_cart_qty | update_cart_quantity | cart.updateQty | { product_external_id, quantity } |
update_cart_qty ≠ update_cart_quantityThe quantity capability has a model-name vs runtime-action asymmetry. The capability is
cart.update_qty, the model-facing tool the bot calls is update_cart_qty, but the runtime action
dispatched to the host bridge is update_cart_quantity. A host that wires a raw runtime handler under
the key update_cart_qty will get No handler for update_cart_qty and the quantity change silently
fails. The typed cart.updateQty field / updateCartQty SDK prop already point at update_cart_quantity
— prefer them. If you route the raw runtime action yourself, register update_cart_quantity.
// Low-level / raw runtime dispatch (custom bridge router or mobile native callback)
window.VoiceBotSyncBridge = {
protocolVersion: 1,
capabilities: ['cart.add', 'cart.remove', 'cart.update_qty'],
toolHandlers: {
add_to_cart: (args) => myCart.add(args.product_external_id, args.variation_external_id, args.quantity),
remove_from_cart: (args) => myCart.remove(args.product_external_id),
update_cart_quantity: (args) => myCart.setQty(args.product_external_id, args.quantity), // NOT update_cart_qty
},
};
Prefer the typed cart.* fields (or the SDK onAction props) over a raw toolHandlers map — they hide
this asymmetry and validate the argument shapes for you. The raw form is documented here only because a
host that takes it must use update_cart_quantity.
apply_filter must update the visible filter UI
When the bot calls apply_filter on a category page, your handler must update both halves of the
listing — not just one:
- The product listing (the grid / results / URL query) — what most hosts already do.
- The visible filter controls — the checkboxes, colour swatches, in-stock toggle, and active-filter badges in your filter sidebar. The shopper must see the filter the bot applied reflected in the UI, exactly as if they had clicked it themselves. A listing that quietly re-queries while the sidebar still shows "no filters" looks broken.
Value shapes the backend sends
apply_filter passes your own filter tokens straight through (the taxonomy and value are copied
verbatim from the category's ingested available_filters, so they map 1:1 onto your controls). Compare
against the same token your control already uses:
| Taxonomy kind | value shape | Compare against |
|---|---|---|
| brand | brand slug (e.g. apple) | your brand option's slug |
| colour | hex colour (e.g. #FFFFFF) | your swatch's hex value |
| in-stock | boolean (true / false) | your in-stock toggle state |
mode is 'set' (replace the current selection for this taxonomy), 'add' (append), or 'remove'
(clear). Drive your sidebar state from it the same way a click would.
Re-sync the filter sidebar (React)
Treat apply_filter as an externally-driven state change: write it into the same store your filter
controls render from, and let the sidebar re-render. Don't update the listing in isolation.
import { VoiceBotWidget } from '@monoverse/voicebot-react';
import { useFilterStore } from './store';
export function StoreWidget() {
const applyExternalFilter = useFilterStore((s) => s.applyExternalFilter);
return (
<VoiceBotWidget
publicKey="pk_…"
capabilities={['catalog.facets']}
onAction={{
// value is the host's own token: brand=slug, colour=hex, in_stock=boolean
applyFilter: ({ taxonomy, value, mode }) => {
applyExternalFilter(taxonomy, value, mode); // updates the store the sidebar reads…
return { status: 'executed_ok' };
},
}}
/>
);
}
// store.ts — the SAME store the FilterSidebar checkboxes/swatches render from,
// so writing here re-syncs the controls AND the listing in one render.
function applyExternalFilter(taxonomy: string, value: string, mode: 'set' | 'add' | 'remove') {
setState((prev) => ({
selected: nextSelection(prev.selected, taxonomy, value, mode), // checkboxes, swatches, in-stock toggle
activeBadges: nextBadges(prev.selected, taxonomy, value, mode), // active-filter badges
}));
// listing re-queries from `selected` via your existing selector — no separate update needed
}
The point is the single source of truth: if the sidebar and the listing both render from one filter store, writing the bot's filter into that store updates both. If they don't, update both explicitly.
Site locale → bot start language
The bot opens the conversation in the visitor's page locale. It greets a shopper on your /ru pages
in Russian and on your /uk pages in Ukrainian, automatically — but only if your page tells it which
locale it is.
The widget detects the locale, in order, from:
- an explicit
locale/langpassed to the widget config (data-langon the script tag, or the SDKlangprop); document.documentElement.lang— thelangattribute on<html>;navigator.language— the browser's language.
The first non-empty value wins. So the host must set an accurate <html lang="…"> for the current
page's locale — ru on a /ru page, uk on a /uk page — or pass an explicit locale to the widget
config. If you serve multiple locales under different paths but render the same static <html lang="uk">
everywhere, every visitor is greeted in Ukrainian regardless of the page they're on.
<!-- on a /ru page -->
<html lang="ru">
…
<script
src="https://api.monoverse.tech/widget/widget-voice.js"
data-public-key="pk_…"
async
></script>
</html>
// SPA / SSR alternative — pass the page locale explicitly when <html lang> is static
<VoiceBotWidget publicKey="pk_…" lang={pageLocale /* 'ru' | 'uk' | … */} />
The visitor's detected locale takes priority. The assistant language a merchant configures in the
cabinet is used only as a fallback when the visitor's locale can't be determined (no lang config, no
<html lang>, no navigator.language). Conveying the page locale is therefore how a shopper on a
non-default-language page gets greeted in their own language — the merchant default does not override it.
Catalog price units (minor units)
Catalog prices are synced and handled in minor units (kopecks): a product that costs 3100 ₴ is
ingested as price_amount = 310000. This is the canonical wire shape — see the *_amount fields in the
Data reference (199.99 UAH → 19999). Sending a major-unit value
(3100) where minor units are expected is a 100× price error.
Downstream, the value is converted for you:
| Surface | Field | Unit | Example for a 3100 ₴ product |
|---|---|---|---|
| Ingest / sync (what your plugin sends) | price_amount | minor (kopecks) | 310000 |
| Widget render (product cards) | price_uah | major | 3100 |
| Voice model (what the bot is given) | price | major | 3100 |
You only set the minor-unit price_amount on ingest; the widget's price_uah and the model's price
are derived major-unit values you don't send. Don't pre-divide on the sync side and don't re-multiply in a
bridge handler — convert exactly once, at the ingest boundary, into minor units.
Confirmation behavior
Understanding which actions require explicit user confirmation prevents confusion:
| Action category | Confirmation required | How |
|---|---|---|
Catalog reads (search_catalog, get_product, compare_products) | None | Runs silently |
Navigation (open_product, navigate, view_cart) | None | Runs silently |
Variant selection (select_variant) | None | Reversible; runs after the user specifies a variant in conversation |
Cart writes (add_to_cart, remove_from_cart, update_cart_qty) | Conversational consent | Executes after the shopper says yes (voice or text) — no extra UI button |
Checkout / form submit (submit_checkout, submit_form) | Explicit user click | Bot highlights the submit button; user must click it — the bot never auto-submits |
Cart actions do not render a confirmation button UI — they execute as soon as the shopper gives conversational agreement ("yes", "add it", "sure", etc.). This is intentional: a voice interaction already implies the user's intent by the time the bot asks.
Irreversible submit actions (checkout, forms) always require a physical click to prevent accidental auto-pay or accidental form submission.
Verify your integration
After wiring both halves, run these checks:
- Grant check — in the VoiceBot dashboard, open your tenant's sync coverage. The
host_profileentity should show1 record, hostwith the capabilities you declared. - Catalog check — ask the bot "show me your phones" (or similar). It should return real products from your catalog, not an error.
- Variant check — ask about a configurable product by name, then say "the red one, size 41". The
bot should name the axes from your catalog (not invented ones), your
selectVarianthandler fires, and the PDP UI updates. - Add-to-cart check — say "add it to my cart". The bot asks for confirmation, you say yes, your
addToCarthandler fires, and the item appears in the cart. - Narrow test — on a static blog page, pass an empty
capabilities: []to the widget. Confirm the bot does not offer cart actions on that page.
See also
- Capabilities & host actions — the full capability vocabulary, bridge type reference, and variant selection flow.
- Laravel mapping — how to map your Eloquent models to the canonical entity schema.
- Node Server Sync — the Node.js ingest client.
- Python Server Sync — the Python ingest client.
- Ingest Protocol Reference — raw
host_profileandproductentity payloads.