Skip to main content
Version: v1

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 16 kinds:

site, environment, product, variation, category, tag, attribute,
page, post, cpt, menu, menu_item, form, popup, shipping_method, payment_method

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 UAH is 54999, never a float or a string. Fields: price_amount, regular_price_amount, sale_price_amount. Currency is a separate ISO-4217 string ("UAH").
  • categories and tags are slug lists["smartphones", "apple"], not objects and not numeric ids.
  • page.content_text is plain text. Strip HTML and decode entities before sending; it feeds retrieval. (content_html may 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 unit549.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
)
Never send a float for money

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,
)
Stream, never materialise

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.