Skip to main content
Version: next

Building entities

You map your data to Monoverse'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 UAH is 54999, never a float or a string. Fields: price_amount and regular_price_amount; sale_price_amount is ignored, so the price a shopper pays now goes in 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, inline_variation (one variant inside a product — see Variable products), 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 (on a product and on an inline variant) 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
)
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
)

Variable products​

A product that comes in sizes or colours sends its variants inside the product upsert. product() takes three lists for that:

  • variant_axes — what a shopper chooses, each {"name": …, "slug": …, "values": [{"label": …, "slug": …, "external_id": …}]} (slug and external_id are optional). A value's external_id is the stable id the assistant passes back.
  • variations — one entry per variant, built with inline_variation(). Its attributes keys should match an axis name.
  • attributes — descriptive attributes that are not axes, such as {"name": "Material", "values": ["Textile"]}; sent as-is.
from monoverse_voicebot import inline_variation, product

runner = product(
external_id="shop:product:runner",
name="Runner",
price_amount=179900,
currency="UAH",
attributes=[{"name": "Material", "values": ["Textile"]}],
variant_axes=[
{
"name": "Size",
"values": [
{"label": "41", "external_id": "shop:size:41"},
{"label": "42", "external_id": "shop:size:42"},
],
}
],
variations=[
inline_variation(
external_id="shop:variation:runner-41",
sku="RUN-41",
price_amount=189900, # 1899.00 UAH
regular_price_amount=199900,
stock_status="instock",
stock_quantity=3,
attributes={"Size": "41"},
),
inline_variation(
external_id="shop:variation:runner-42",
sku="RUN-42",
price_amount=179900,
stock_status="outofstock",
attributes={"Size": "42"},
),
],
)

inline_variation(*, external_id, sku=None, price_amount=None, regular_price_amount=None, stock_status=None, stock_quantity=None, attributes=None) returns the variant as a wire-shaped dict. Sending variations or variant_axes makes it a variable product: the server infers product_type "variable".

  • Inline amounts are integer minor units too — 189900 is 1899.00 UAH. inline_variation() raises ConfigError on a float, a negative or a bool, and product() checks raw dicts in variations again, naming the field (variations[1].price_amount). The server reads inline variant prices in minor units, so they land as sent.
  • Send the whole product every time. An upsert replaces the product, variants included: one that leaves out variations stores the product with an empty variant list. Send every variation, attribute and axis on each upsert, not only the variant that changed.

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.