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 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 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 unit549.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
});
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. 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,
});
}
}
Stream, never materialise

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.