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 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 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 a single options object. Optional fields you omit are simply not sent.
import { product, category, page, site } from '@monoverse/voicebot-node';
const phones = category({ externalId: 'shop:category:phones', name: 'Phones', slug: 'phones' });
const iphone = product({
externalId: 'shop:product:iphone-15-pro',
name: 'iPhone 15 Pro',
sku: 'IP15P-256',
slug: 'iphone-15-pro',
priceAmount: 54999, // 549.99 → 54999 minor units
currency: 'UAH',
stockStatus: 'instock',
categories: ['phones'], // slug list
permalink: 'https://shop.example.com/p/iphone-15-pro',
});
Available builders: product, variation, category, tag, page, site, shippingMethod,
paymentMethod, plus the generic entity(kind, externalId, payload) escape hatch for environment,
form, popup, menu, menu_item and attribute.
The rules the builders enforce
1. Money is integer minor units
priceAmount, regularPriceAmount and (on variation) the amount fields are integers in the
currency's minor unit — 549.99 UAH is 54999, never a float. assertMinorUnits throws on a float
or a negative:
import { assertMinorUnits } from '@monoverse/voicebot-node';
product({
externalId: 'shop:product:42',
name: 'Coffee',
priceAmount: Math.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. Math.round(price * 100) is the canonical conversion.
2. Categories and tags are slug lists
Reference taxonomy by slug strings, not objects or ids. assertSlugList validates each entry:
product({
externalId: 'shop:product:42',
name: 'Coffee',
categories: ['kava', 'napoi'], // not objects, not ids
tags: ['arabica'],
});
3. Content is plain text
page.contentText is plain text for retrieval — strip HTML before sending:
import { page } from '@monoverse/voicebot-node';
page({
externalId: 'shop:page:shipping',
title: 'Shipping & returns',
contentText: stripHtml(article.body), // your HTML→text step
permalink: 'https://shop.example.com/shipping',
});
4. Parent references use the wrapped external id
Hierarchy (category.parentExternalId, variation.parentExternalId, menu_item) references the parent
by its full external id — the same id you assign that parent. Pass undefined for roots:
category({
externalId: 'shop:category:smartphones',
name: 'Smartphones',
parentExternalId: '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 a sync or async iterable:
async function* catalog() {
for await (const row of db.streamProducts()) {
yield product({
externalId: `shop:product:${row.id}`,
name: row.title,
priceAmount: Math.round(row.price * 100),
currency: 'UAH',
stockStatus: row.inStock ? 'instock' : 'outofstock',
categories: row.categorySlugs,
});
}
}
Yield entities from a generator backed by a DB cursor. Building an array of the whole catalog defeats the streaming snapshot and can OOM on large stores.
Translations (optional)
Tag a single-language record with lang and link translations with translationOf (the source record's
external id):
page({
externalId: 'shop:page:about-en',
title: 'About us',
contentText: '…',
lang: 'en',
translationOf: '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.