Capabilities & host actions
The bot can search your catalog, pick a product variant, filter a listing, compare products, add to cart, and more — on any storefront, not just WooCommerce. It does this through one capability contract: you declare what your host can do once (the same way you declare your catalog once through the ingest protocol), and the bot only ever offers and dispatches actions your host actually supports.
There is no branching on host identity anywhere in the engine. A capability is the single key that gates a host-acting tool. WooCommerce is just one host whose signals are auto-mapped into the same vocabulary; a custom SPA, a Laravel storefront, or a headless frontend declares the same capabilities directly and gets the same behaviour.
If you are not on WooCommerce, this page is your integration guide. Work top to bottom: understand the vocabulary → declare your capabilities → push variant data → wire the client bridge → verify. The end-to-end walkthrough at the bottom ties it together.
The capability vocabulary
Capabilities are a closed, versioned set of identifiers. Each one gates one or more tools the bot may call. You cannot invent new capability strings — the server ignores anything outside this set (adding a capability is a versioned change to the contract, not a per-host extension).
| Capability | Gates | Kind | In the baseline? |
|---|---|---|---|
catalog.search | search_catalog | read | ✅ always |
catalog.get_product | get_product (incl. variant axes / attributes) | read | ✅ always |
catalog.compare | compare_products | read | ✅ always |
catalog.facets | apply_filter, filter_by_attribute | read | ✅ always |
catalog.sort | apply_sort | control (reversible) | declare |
nav.open | open_product, open_category, open_page, navigate | nav | ✅ always |
nav.search_page | open_search_with_query, search | nav | declare |
variant.select | select_variant | selection (reversible) | declare |
cart.add | add_to_cart | write | declare |
cart.remove | remove_from_cart | write | declare |
cart.update_qty | update_cart_qty | write | declare |
cart.view | view_cart | nav | declare |
checkout.assist | list_checkout_fields, validate_checkout_fields, focus_input, fill_input, set_select, set_checkbox, submit_checkout | assist | declare |
form.submit | submit_form | write | declare |
The catalog-ingested baseline
The moment you have an ingested catalog, the bot always has the read/navigation baseline —
catalog.search, catalog.get_product, catalog.compare, catalog.facets, and nav.open — with no
declaration at all. So a freshly-synced host can already search, recommend, compare, and link to
products. You only declare the capabilities beyond the baseline: variant selection, sort
(catalog.sort), the cart writes, cart.view, checkout.assist, nav.search_page, and form.submit.
Reads, selections, and writes
- read / nav capabilities are safe and unconfirmed.
variant.selectis a reversible selection (the bot resolves which variation the shopper wants); it is not a write and is not confirmation-gated.- Cart writes —
cart.add,cart.remove,cart.update_qty— execute after the shopper gives conversational consent (voice or chat). There is no extra UI confirmation button for cart actions; the spoken or typed agreement is sufficient. form.submit(checkout and form submissions) is confirmation-gated with a required explicit user action — the bot highlights the submit control and waits for a click, never auto-submitting.
The distinction matters: cart actions flow naturally in conversation; irreversible submit actions require a physical interaction from the user.
A client may narrow the granted set (advertise fewer capabilities than the server resolved — for
example to disable cart actions on a read-only page) but can never widen it. A page that advertises
cart.add it wasn't granted does not get it. This is enforced server-side; the client advertisement can
only remove.
1. Declare your capabilities — the host_profile entity
A host declares its capabilities by pushing a host_profile ingest entity through the exact same
signed-ingest / sync channel you already use for products and categories (see the
Ingest Protocol Reference). It is a singleton entity —
external_id is "host".
{
"kind": "host_profile",
"external_id": "host",
"payload": {
"capabilities": ["cart.add", "cart.remove", "cart.update_qty", "cart.view", "variant.select"],
"cart_endpoint": null,
"metadata": {}
}
}
| Field | Type | Notes |
|---|---|---|
capabilities | string[] | Capability ids from the closed vocabulary above. Unknown strings are dropped; duplicates are de-duped. The baseline is granted regardless, so list only what's beyond the baseline. |
cart_endpoint | string | null | Optional. A server-side cart endpoint, if your host exposes one. Omit for client-bridge-only carts. |
metadata | object | Optional free-form host metadata. |
A WooCommerce host's environment entity already carries wc_capabilities
(ajax_add_to_cart, rest_store_api, cart_fragments, checkout_blocks). Those are auto-mapped
into the vocabulary (ajax_add_to_cart/rest_store_api → the four cart.* capabilities,
checkout_blocks → checkout.assist, …). A WooCommerce store gets cart/checkout capabilities for free.
A non-WooCommerce host declares them explicitly via host_profile. Either path lands in the same
vocabulary — the engine never sees the difference.
2. Push catalog data the bot can act on — variants & attributes
For the bot to pick a colour/size or filter by a facet, that structure has to be in the catalog you
push. A configurable product carries variant_axes (the choice dimensions), optional attributes
(non-variant facets), and optional concrete variations:
{
"kind": "product",
"external_id": "laravel:product:123",
"payload": {
"name": "Кросівки Runner",
"product_type": "variable",
"variant_axes": [
{
"name": "Колір",
"slug": "color",
"values": [
{ "label": "Червоний", "slug": "red", "external_id": "opt:red" },
{ "label": "Синій", "slug": "blue" }
]
},
{ "name": "Розмір", "values": [{ "label": "41" }, { "label": "42" }, { "label": "43" }] }
],
"attributes": [{ "name": "Матеріал", "values": ["Текстиль"] }],
"variations": [
{
"external_id": "laravel:variation:123-red-41",
"attributes": { "Колір": "Червоний", "Розмір": "41" },
"price_amount": 189900,
"stock_status": "instock"
}
]
}
}
product_typeis inferred tovariablewhenvariant_axesorvariationsare present, so you don't strictly have to set it — but set it if you have it. Without axes/variations a product issimple.variant_axes[].values[].external_idis optional but recommended: it's the stable id the bot passes back when it resolves a variation.- Canonical is preferred. If your data only has flat keys (
colors,sizes,color,size,material,option…), the ingest boundary will normalise common synonyms intovariant_axesas a fallback — but send the canonical shape when you can; it's unambiguous and locale-correct.
Once this lands, get_product returns the axes/attributes and they appear in the per-tenant grounding
block the bot sees, so it filters and picks variants from a known vocabulary instead of guessing.
Category controls — ground the bot on real filters & sort
Filtering and sorting are grounded on what each category actually exposes, declared on the
category ingest entity (both fields are additive and optional):
{
"external_id": "laravel:category:12",
"available_filters": [
{ "taxonomy": "brand", "label": "Бренд", "values": [
{ "value": "apple", "label": "Apple" },
{ "value": "samsung", "label": "Samsung" }
] },
{ "taxonomy": "color", "label": "Колір", "values": [
{ "value": "red", "label": "Червоний" }
] }
],
"available_sorts": [
{ "sort_key": "price", "label": "Спочатку дешевші" },
{ "sort_key": "price-desc", "label": "Спочатку дорожчі" },
{ "sort_key": "rating", "label": "За рейтингом" }
]
}
The bot is grounded only on these declared options for the current category — it offers your real filters and sorts and cannot invent ones that don't exist (closed-vocabulary grounding). A category that omits them simply has nothing to offer there, and the bot says so.
available_filtersmakes filtering category-accurate — without it, faceted filtering falls back to guessing from product attributes, which can offer filters the page doesn't have and miss ones it does.available_sortsis what makesapply_sortreachable at all — a category with no declared sorts is not sortable by voice.
The sort_key / filter value the bot picks are passed straight back to your applySort / applyFilter
handler unchanged — they are your keys, so they map 1:1 onto your sort control and filter UI. On
WooCommerce these are auto-derived (registered pa_* attributes → filters; the standard orderby set →
sorts); non-WC hosts (Laravel / Node / custom) declare them directly.
3. Wire the client — the host bridge
Read/nav/compare tools run entirely on the backend — the bot just speaks the result, your app does
nothing. The acting tools (add to cart, select a variant, apply a filter, …) are pushed to the
widget, which routes them to your host through window.VoiceBotSyncBridge. You provide that bridge
either directly (vanilla) or via the SDK's onAction prop (React/Vue/Next).
- SDK (React / Vue / Next)
- Vanilla — window.VoiceBotSyncBridge
The embed wrappers forward onAction, capabilities, and toolHandlers onto the bridge for you:
import { VoiceBotWidget } from '@monoverse/voicebot-react';
import { cart } from './my-store';
<VoiceBotWidget
publicKey="pk_…"
capabilities={['cart.add', 'cart.remove', 'cart.update_qty', 'variant.select', 'catalog.facets']}
onAction={{
addToCart: ({ product_external_id, variation_external_id, quantity }) => {
cart.add(product_external_id, variation_external_id, quantity);
return { status: 'executed_ok' };
},
removeFromCart: ({ product_external_id }) => cart.remove(product_external_id),
updateCartQty: ({ product_external_id, quantity }) => cart.setQty(product_external_id, quantity),
selectVariant: ({ product_external_id, axis_values }) => {
// axis_values e.g. { "Колір": "Червоний", "Розмір": "41" } — drive your PDP selectors
pdp.selectVariant(product_external_id, axis_values);
return { status: 'executed_ok' };
},
applyFilter: ({ taxonomy, value, mode }) => productList.filter(taxonomy, value, mode),
}}
/>
A handler returns true / void (ok), false (failed), or a result object
{ status, result?, error? }.
@monoverse/voicebot-vue exposes the same onAction / capabilities / toolHandlers props with full
parity — Vue example:
<script setup lang="ts">
import { VoiceBotWidget } from '@monoverse/voicebot-vue';
import { cart } from './my-store';
</script>
<template>
<VoiceBotWidget
public-key="pk_…"
:capabilities="['cart.add', 'cart.remove', 'cart.update_qty', 'variant.select']"
:on-action="{
addToCart: ({ product_external_id, variation_external_id, quantity }) => {
cart.add(product_external_id, variation_external_id, quantity);
return { status: 'executed_ok' };
},
removeFromCart: ({ product_external_id }) => cart.remove(product_external_id),
updateCartQty: ({ product_external_id, quantity }) => cart.setQty(product_external_id, quantity),
selectVariant: ({ product_external_id, axis_values }) => {
pdp.selectVariant(product_external_id, axis_values);
return { status: 'executed_ok' };
},
applyFilter: ({ taxonomy, value, mode }) => productList.filter(taxonomy, value, mode),
}"
/>
</template>
@monoverse/voicebot-next (App Router) wires the same options from a Client Component. The SDK's
installHostBridge helper does the same wiring if you mount the widget yourself.
Set the bridge before the widget boots:
window.VoiceBotSyncBridge = {
protocolVersion: 1,
capabilities: ['cart.add', 'cart.remove', 'cart.update_qty', 'variant.select', 'catalog.facets'],
cart: {
add: ({ product_external_id, variation_external_id, quantity }) => {
myCart.add(product_external_id, variation_external_id, quantity);
return { status: 'executed_ok' };
},
remove: ({ product_external_id }) => myCart.remove(product_external_id),
updateQty: ({ product_external_id, quantity }) => myCart.setQty(product_external_id, quantity),
view: ({ fallback_url }) => myCart.open() ?? { status: 'unsupported' },
},
selectVariant: ({ product_external_id, axis_values }) => pdp.select(product_external_id, axis_values),
applyFilter: ({ taxonomy, value, mode }) => productList.filter(taxonomy, value, mode),
};
Argument shapes
| Action | Args your handler receives |
|---|---|
| add to cart | { product_external_id, variation_external_id?, quantity } |
| remove from cart | { product_external_id } |
| update cart qty | { product_external_id, quantity } |
| view cart | { fallback_url? } |
| select variant | { product_external_id, axis_values: Record<string,string> } |
| apply filter | { taxonomy, value, mode: 'set' | 'add' | 'remove' } |
| apply sort | { sort_key, category_external_id? } |
Handlers always receive the clean product_external_id name (the bridge maps it from the wire shape
for you).
update_cart_quantity, not update_cart_qtyThe cart-quantity capability is cart.update_qty and the model-facing tool the bot calls is
update_cart_qty, but the runtime action dispatched to the host is update_cart_quantity. The
typed cart.updateQty field and the updateCartQty SDK prop already target it — use them. Only if you
route the raw runtime action through the low-level toolHandlers map (or a mobile native callback) do you
register the runtime name, and it must be update_cart_quantity — keying it update_cart_qty yields
No handler for update_cart_qty and the quantity change silently fails. (cart.add / cart.remove have
no such gap: their runtime actions are add_to_cart / remove_from_cart, matching the tool names.)
selectVariant, not toolHandlers.select_variantselect_variant is not a merchant-overridable handler — it is dispatched to the first-class
bridge.selectVariant (or SDK onAction.selectVariant). Registering it under the low-level
toolHandlers map will silently never fire. Same first-class path for the typed cart.*,
applyFilter, and applySort entries; toolHandlers is only the escape hatch for the older canonical action names.
Resolution order & graceful degradation
For each acting action the widget resolves a handler in this order, stopping at the first that exists:
toolHandlers[action] (low-level override, highest precedence)
→ bridge.cart.* / bridge.applyFilter / bridge.applySort / bridge.selectVariant (first-class, recommended)
→ WooCommerce built-in (only if WC globals are present on the page)
→ generic DOM fallback
→ unsupported
If nothing handles an action it resolves to unsupported — your app never crashes, and the bot
adapts (it stops offering that action and steers elsewhere). You only wire what you support. Cart
writes additionally pass through the shopper-confirmation gate before your handler runs.
Advertising capabilities from the client
Pass capabilities (SDK prop or bridge.capabilities) to advertise what the current page can do.
This is narrow-only: it can hide capabilities the server granted (e.g. no cart on a blog page) but
cannot grant new ones. Omit it to use the full server-resolved set.
window.VoiceBotSyncBridge reference
The bridge is the low-level surface. When you use the React/Vue/Next SDK, installHostBridge writes
to this object for you — you never touch it directly. If you use vanilla JS or need manual control,
set the bridge before the widget script boots:
window.VoiceBotSyncBridge = {
protocolVersion: 1, // required, always 1
capabilities: [ // optional narrow-only set (see §Advertising capabilities above)
'cart.add', 'cart.remove', 'cart.update_qty', 'cart.view',
'variant.select', 'catalog.facets', 'catalog.sort',
],
// --- cart actions ---
cart: {
add: ({ product_external_id, variation_external_id, quantity }) => {
return myCart.addItem(product_external_id, variation_external_id, quantity);
},
remove: ({ product_external_id }) => {
return myCart.removeItem(product_external_id);
},
updateQty: ({ product_external_id, quantity }) => {
return myCart.setQty(product_external_id, quantity);
},
view: ({ fallback_url }) => {
return myCart.open() || window.location.assign(fallback_url ?? '/cart');
},
},
// --- variant selection (reversible, no confirmation) ---
selectVariant: ({ product_external_id, axis_values }) => {
pdp.applySelection(product_external_id, axis_values);
return { status: 'executed_ok' };
},
// --- facet / attribute filtering ---
applyFilter: ({ taxonomy, value, mode }) => {
productList.filter(taxonomy, value, mode);
return { status: 'executed_ok' };
},
// --- sort the current listing (reversible) ---
applySort: ({ sort_key, category_external_id }) => {
productList.sort(sort_key, category_external_id);
return { status: 'executed_ok' };
},
// --- low-level escape hatch for canonical action names ---
// Use only for actions not covered by the typed fields above.
toolHandlers: {
navigate: ({ params }) => router.navigate(params?.url),
},
};
Full type reference
| Field | Type | Description |
|---|---|---|
protocolVersion | 1 | Always 1. Required. |
capabilities | HostCapability[] | Optional. Narrows the server-granted set for the current page. |
cart.add | (args: CartAddArgs) => HostActionOutcome | Called when the bot adds a product to cart. |
cart.remove | (args: CartRemoveArgs) => HostActionOutcome | Called when the bot removes a line item. |
cart.updateQty | (args: CartUpdateQtyArgs) => HostActionOutcome | Called when the bot changes a line item's quantity. |
cart.view | (args: CartViewArgs) => HostActionOutcome | Called when the bot opens the cart. |
selectVariant | (args: SelectVariantArgs) => HostActionOutcome | Called when the bot selects a variant on a configurable product. |
applyFilter | (args: ApplyFilterArgs) => HostActionOutcome | Called when the bot applies a facet filter. |
applySort | (args: ApplySortArgs) => HostActionOutcome | Called when the bot sorts the current listing. |
toolHandlers | Record<string, handler> | Low-level overrides for canonical action names. Used only for actions not covered by the typed fields. |
Argument types:
CartAddArgs = { product_external_id: string; variation_external_id?: string; quantity: number }
CartRemoveArgs = { product_external_id: string }
CartUpdateQtyArgs = { product_external_id: string; quantity: number }
CartViewArgs = { fallback_url?: string }
SelectVariantArgs = { product_external_id: string; axis_values: Record<string, string> }
ApplyFilterArgs = { taxonomy: string; value: string; mode: 'set' | 'add' | 'remove' }
ApplySortArgs = { sort_key: string; category_external_id?: string }
Handler return values:
| Return value | Meaning |
|---|---|
void / true | Success. |
false | Failed (generic). |
{ status: 'executed_ok' } | Success with structured result. |
{ status: 'executed_partial' } | Partial success. |
{ status: 'user_cancelled' } | User cancelled the action. |
{ status: 'failed', error: { code, message } } | Failed with detail. |
{ status: 'unsupported' } | This page cannot handle this action. |
All handlers may return a Promise.
Variant selection flow
When a shopper says "show me the red one, size 41", the bot runs a two-step sequence before adding to cart:
-
select_variant— the bot callsselect_variant({ product_external_id, axis_values })whereaxis_valuesis e.g.{ "Колір": "Червоний", "Розмір": "41" }. The backend resolves the concretevariation_external_idfrom the ingestedvariant_axes/variationson the product. YourselectVarianthandler receives the axes and drives your PDP UI so the user sees the selection applied visually. This is not a write — no confirmation prompt, fully reversible. -
add_to_cart— the bot follows up withadd_to_cart({ product_external_id, variation_external_id, quantity })wherevariation_external_idis the resolved variation id (e.g."laravel:variation:123-red-41"). YouraddToCarthandler receives the complete set of arguments. This is a write; it executes after conversational consent.
End-to-end example for a configurable product (colour + size):
User: "Add the red runner in size 41 to my cart"
Bot → select_variant({ product_external_id: "laravel:product:123",
axis_values: { "Колір": "Червоний", "Розмір": "41" } })
→ backend resolves variation_external_id = "laravel:variation:123-red-41"
→ your bridge.selectVariant / onAction.selectVariant fires
→ your PDP highlights "Red / 41"
Bot: "Found it — Кросівки Runner, Red, size 41 for 1 899 ₴. Add to cart?"
User: "Yes"
Bot → add_to_cart({ product_external_id: "laravel:product:123",
variation_external_id: "laravel:variation:123-red-41",
quantity: 1 })
→ your bridge.cart.add / onAction.addToCart fires
→ item lands in the cart
Bot: "Done — added to your cart."
selectVariant and addToCart togetherFor a smooth experience, selectVariant should visually select the option on the page and
addToCart should use your standard cart API. If your cart API requires a variation_id, use
variation_external_id directly — it is the same stable id you sent when ingesting the product's
variations array.
End-to-end: bring your own host
A complete non-WooCommerce integration (this is exactly how a custom Laravel + React storefront wires up):
- Declare capabilities. Add a
host_profileentity to your sync with the capabilities your store supports beyond the baseline — e.g.["cart.add","cart.remove","cart.update_qty","variant.select","catalog.facets"]. (§1) - Push variant-aware catalog. Send configurable products with
variant_axes/attributes/variationsso the bot can see and pick options. (§2) - Wire the bridge. Provide
onAction(SDK) orwindow.VoiceBotSyncBridge(vanilla) handlers for the acting capabilities you declared, mapping the bot's calls onto your cart / PDP / product-list APIs. (§3) - Verify. Confirm with the checklist below.
Verify
- Sync ran: your
host_profileand variant products are ingested (check the dashboard sync coverage). - Ask the bot to find a configurable product → it should offer the real axes (colours/sizes) from your catalog, not deny them or invent values.
- Ask it to "add the red one, size 41" → it resolves the variation (
select_variant) and callsadd_to_cartafter conversational consent; youronAction.selectVariantfires first (PDP updates), thenonAction.addToCartfires once you say yes. - Ask it to filter ("show only red") → it calls
apply_filterwith a facet value from your catalog. - Ask it to sort ("cheapest first") → it calls
apply_sortwith asort_keyfrom the category'savailable_sorts. - On a page that advertises a narrowed
capabilitiesset, the hidden actions are not offered.
See also
- Tool handlers & deep links — the full web-bridge and mobile-SDK action surface.
- Ingest Protocol Reference — the
host_profileandproductentity payloads. - Tenant isolation — capabilities and catalog are per-tenant.