Building entities
You map your data to VoiceBot's canonical entity schema with the package's typed builder functions.
Each builder validates the rules that matter (money, slugs) and returns a CanonicalEntity (a frozen
Pydantic model) you stream into a snapshot or wrap into a
delta op.
The entity envelope
The catalog and content you push are a stream of entities. In a full snapshot each entity is one
line of NDJSON; in a delta each is one operation in an /events batch. Every entity has the same
envelope:
{
"kind": "<entity_kind>",
"external_id": "<your-stable-id>",
"lang": "<locale | null>",
"translation_of": "<external_id | null>",
"payload": { /* kind-specific */ }
}
kind is the discriminator. There are 17 kinds:
site, environment, host_profile, product, variation, category, tag, attribute,
page, post, cpt, menu, menu_item, form, popup, shipping_method, payment_method
host_profile is a singleton (external_id: "host") that declares which capabilities the host
supports beyond the read/navigation baseline — cart writes, variant selection, and so on. See
Capabilities & host actions. WooCommerce hosts get these auto-derived from
environment.wc_capabilities and don't need to send it.
external_id is your stable, opaque identifier. The canonical shape is <source>:<kind>:<id> (for
example wc:product:iphone-15-pro or laravel:category:42); the server stores it verbatim and uses
it to upsert and to resolve parent references.
Three rules that catch everyone
- Money is integer minor units.
549.99 UAHis54999, never a float or a string. Fields:price_amount,regular_price_amount,sale_price_amount. Currency is a separate ISO-4217 string ("UAH"). categoriesandtagsare slug lists —["smartphones", "apple"], not objects and not numeric ids.page.content_textis plain text. Strip HTML and decode entities before sending; it feeds retrieval. (content_htmlmay carry the original markup separately if you need it for rendering.)
The only universally required field is product.name (and page.title / form.title for those
kinds). Everything else is optional with a sensible default; unknown extra fields are ignored, so a
newer producer can send fields an older backend hasn't learned yet without breaking validation.
The typed builders
Import a builder per kind; pass keyword arguments. Optional fields you omit are simply not sent.
from monoverse_voicebot import product, category, page, site
phones = category(external_id="shop:category:phones", name="Phones", slug="phones")
iphone = product(
external_id="shop:product:iphone-15-pro",
name="iPhone 15 Pro",
sku="IP15P-256",
slug="iphone-15-pro",
price_amount=54999, # 549.99 -> 54999 minor units
currency="UAH",
stock_status="instock",
categories=["phones"], # slug list
permalink="https://shop.example.com/p/iphone-15-pro",
)
Available builders: product, variation, category, tag, page, site, shipping_method,
payment_method, plus the generic entity(kind, external_id, payload) escape hatch for environment,
form, popup, menu, menu_item and attribute.
The rules the builders enforce
1. Money is integer minor units
price_amount, regular_price_amount and (on variation) the amount fields are integers in the
currency's minor unit — 549.99 UAH is 54999, never a float. assert_minor_units raises on a float
or a negative:
from monoverse_voicebot import product
product(
external_id="shop:product:42",
name="Coffee",
price_amount=round(price * 100), # the canonical conversion
)
Passing 199.99 instead of 19999 is the single most common mistake — and a 100× price error. The
builder rejects it (and rejects a bool, since True == 1 in Python). round(price * 100) is the
canonical conversion.
2. Categories and tags are slug lists
Reference taxonomy by slug strings, not objects or ids. assert_slug_list validates each entry
(Unicode-aware — Cyrillic slugs are accepted):
product(
external_id="shop:product:42",
name="Coffee",
categories=["kava", "napoi"], # not objects, not ids
tags=["arabica"],
)
3. Content is plain text
page(content_text=...) is plain text for retrieval — strip HTML before sending:
from monoverse_voicebot import page
page(
external_id="shop:page:shipping",
title="Shipping & returns",
content_text=strip_html(article.body), # your HTML→text step
permalink="https://shop.example.com/shipping",
)
4. Parent references use the wrapped external id
Hierarchy (category(parent_external_id=...), variation(parent_external_id=...), menu_item)
references the parent by its full external id — the same id you assign that parent. Omit it for
roots:
category(
external_id="shop:category:smartphones",
name="Smartphones",
parent_external_id="shop:category:phones", # a root would omit this
)
Streaming, not materialising
A builder is a pure function — call it lazily inside a generator so the full catalog never lands in
memory. client.snapshot() accepts any iterable:
def catalog():
for row in db.stream_products(): # a server-side cursor, not a list
yield product(
external_id=f"shop:product:{row.id}",
name=row.title,
price_amount=round(row.price * 100),
currency="UAH",
stock_status="instock" if row.in_stock else "outofstock",
categories=row.category_slugs,
)
Yield entities from a generator backed by a DB cursor. Building a list of the whole catalog defeats the streaming snapshot and can exhaust memory on large stores.
Translations (optional)
Tag a single-language record with lang and link translations with translation_of (the source
record's external id):
page(
external_id="shop:page:about-en",
title="About us",
content_text="…",
lang="en",
translation_of="shop:page:about-uk",
)
Both are optional — omit them for a single-language store.
Reference
The required payload key per kind and the full list of canonical fields the assistant reads live in the Ingest Protocol Reference → Entities. Beyond the required key, send as many canonical fields as you have — the more you send, the better the assistant answers.
Next
- Sync — push these entities as a baseline snapshot, then as deltas.