Skip to main content
Version: v1

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 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 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 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
});
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.