Skip to main content
Version: v1

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:

  1. GRANT (server-side) — your backend tells VoiceBot what your host can do by pushing a host_profile ingest entity. Without it the bot only has the read baseline (catalog search, product lookup, navigation). Cart, variant, and filter actions require an explicit grant.

  2. 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.

Scaffold the client half

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.

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:

config/voicebot.php
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:

CapabilityWhat it enables
cart.addadd_to_cart tool
cart.removeremove_from_cart tool
cart.update_qtyupdate_cart_qty tool
cart.viewview_cart tool
variant.selectselect_variant tool
nav.search_pageopen_search_with_query / search tools
checkout.assistcheckout field-fill tools (form-only, no auto-pay)
form.submitper-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 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),
}}
/>
</>
);
}

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.

CapabilityModel tool nameRuntime action (host handler key)Typed bridge fieldParams your handler receives
cart.addadd_to_cartadd_to_cartcart.add{ product_external_id, variation_external_id?, quantity }
cart.removeremove_from_cartremove_from_cartcart.remove{ product_external_id }
cart.update_qtyupdate_cart_qtyupdate_cart_quantitycart.updateQty{ product_external_id, quantity }
update_cart_qtyupdate_cart_quantity

The 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:

  1. The product listing (the grid / results / URL query) — what most hosts already do.
  2. 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 kindvalue shapeCompare against
brandbrand slug (e.g. apple)your brand option's slug
colourhex colour (e.g. #FFFFFF)your swatch's hex value
in-stockboolean (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:

  1. an explicit locale / lang passed to the widget config (data-lang on the script tag, or the SDK lang prop);
  2. document.documentElement.lang — the lang attribute on <html>;
  3. 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' | … */} />
Visitor locale wins; merchant language is the fallback

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 UAH19999). Sending a major-unit value (3100) where minor units are expected is a 100× price error.

Downstream, the value is converted for you:

SurfaceFieldUnitExample for a 3100 ₴ product
Ingest / sync (what your plugin sends)price_amountminor (kopecks)310000
Widget render (product cards)price_uahmajor3100
Voice model (what the bot is given)pricemajor3100

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 categoryConfirmation requiredHow
Catalog reads (search_catalog, get_product, compare_products)NoneRuns silently
Navigation (open_product, navigate, view_cart)NoneRuns silently
Variant selection (select_variant)NoneReversible; runs after the user specifies a variant in conversation
Cart writes (add_to_cart, remove_from_cart, update_cart_qty)Conversational consentExecutes after the shopper says yes (voice or text) — no extra UI button
Checkout / form submit (submit_checkout, submit_form)Explicit user clickBot 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:

  1. Grant check — in the VoiceBot dashboard, open your tenant's sync coverage. The host_profile entity should show 1 record, host with the capabilities you declared.
  2. Catalog check — ask the bot "show me your phones" (or similar). It should return real products from your catalog, not an error.
  3. 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 selectVariant handler fires, and the PDP UI updates.
  4. Add-to-cart check — say "add it to my cart". The bot asks for confirmation, you say yes, your addToCart handler fires, and the item appears in the cart.
  5. 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