Docs

Catalog: load what you offer, recommend from it

A catalog is the products, articles, courses or listings your site offers, as items of types you declare. Certified items come from you. Items your events mention appear beside them. Slots pick from the certified ones, and reports and segments use the fields of both.

BuiltCatalog is built across the runtime API, the control API, the CLI, the MCP server and the console.

Overview

Four steps from an empty catalog to a recommendation

  1. Declare an item type

    Say what an item is: its id field, its fields, and which fields play a part in picking, filtering and display. Declare the events that mention it, and publish both together.

  2. Load items

    Upsert small batches from your server, or import a whole file as NDJSON or CSV. Items you send are certified.

  3. Let events describe the rest

    Items your events mention that you have not sent appear as observed, once enough different people have seen them. The console shows where the two disagree.

  4. Use it

    Point a slot at the type to recommend certified items, and report on items and segment on their fields.

Two tiers. A certified item comes from your upsert or import and is the only kind a slot returns to a visitor. An observed item is learned from events, so it can be analyzed and segmented on but is never returned. Where both have a field, certified wins and the observed value is kept beside it. Everything on this page is per environment: load development first.

1. Item types

Declare what an item is

An item type is a config object of type item_type. The key is the type id (lowercase letters, digits and underscores, starting with a letter) and the draft says which field is the id and what the other fields are.

Field types

text, keyword
A string. Use keyword for values you compare exactly (an id, a brand), text for titles and descriptions.
keywordList
A list of strings, such as categories. On a dimension, an item keeps at most 8 values.
number
A finite number.
money
An object with exactly amount (a number) and currency (three capital letters).
boolean
true or false.
time
A whole number of milliseconds since the epoch, 0 or more.
url
An http or https address.

Roles

A field takes roles that tell the platform how to use it. A field with no role is stored and shown in the console, and can be reported on.

title, url, image
The payload a pick returns. At most one field of each. title is text or keyword; url and image are url.
serve
Other fields to return with a pick. Title, url and image plus 5 more, cut to 1 KB in all.
available
A boolean. An item with false is not picked. At most one.
published, expires
time fields. An item is picked only between them; the clock decides, so no write is needed when they pass. At most one of each.
dimension
A keyword or keywordList the recommendation scores on and reports group by, such as category, brand or topic. Up to 8 per type.
filter
A field a slot filter may test, such as price or rating. Up to 8 per type.
value
A number or money field that says what a pick is worth, such as margin. At most one.

The helper defineItemType in @usercurrent/config expands three presets into this shape: product (id sku), article (id slug, with published and expires) and generic (id id). The product preset declares price as money, so items for it send {"amount": 129, "currency": "USD"}; the example below uses a plain number instead. A type for anything else, such as courses, listings or recipes, is an ordinary declaration.

product.item-type.jsonExample
{
  "type": "item_type",
  "key": "product",
  "name": "Products",
  "draft": {
    "idField": "sku",
    "fields": {
      "sku":      { "type": "keyword" },
      "title":    { "type": "text",        "roles": ["title"] },
      "url":      { "type": "url",         "roles": ["url"] },
      "image":    { "type": "url",         "roles": ["image"] },
      "price":    { "type": "number",      "roles": ["value", "filter"] },
      "category": { "type": "keywordList", "roles": ["dimension"] },
      "brand":    { "type": "keyword",     "roles": ["dimension"] },
      "inStock":  { "type": "boolean",     "roles": ["available"] }
    },
    "events": ["product_view", "add_to_cart"]
  }
}
A product type with a numeric price, not the preset. Names are examples.

Declare the events that mention the type

Events describe items without a lookup: an event carries the item's id and up to 8 attributes, declared once in the tracking plan as a snapshot. The type's events list names the events that mention it, and every listed event must already declare a snapshot whose item is the type's id field, or the type does not publish. The attributes and the optional numeric value must be declared fields of the type and not personal data, and an attribute's type must agree with the field: a keywordList field takes an array property, and a string where a list is declared is dropped from the observed item. Events with a value count as successes for popularity, the others as views.

For a slot to learn from browsing, the event also declares affinity: the properties that become the visitor's interests, which the slot's dims then name. The signal says how much the event counts (view, click, add_to_cart, purchase or dismiss). Without it the profile keeps no interests and the slot ranks on item value alone.

tracking-plan.jsonExample
{
  "type": "tracking_plan",
  "key": "tracking_plan",
  "name": "Tracking plan",
  "draft": {
    "v": 0,
    "events": [
      {
        "name": "product_view",
        "description": "A product page was viewed.",
        "properties": {
          "sku": { "type": "string" },
          "category": { "type": "array" },
          "brand": { "type": "string" }
        },
        "affinity": { "dimensions": ["category", "brand"], "signal": "view" },
        "snapshot": { "item": "sku", "attributes": ["category", "brand"] }
      },
      {
        "name": "add_to_cart",
        "description": "A product was added to the cart.",
        "properties": {
          "sku": { "type": "string" },
          "category": { "type": "array" },
          "brand": { "type": "string" },
          "price": { "type": "number" }
        },
        "affinity": { "dimensions": ["category", "brand"], "signal": "add_to_cart" },
        "snapshot": { "item": "sku", "attributes": ["category", "brand"], "value": "price" }
      }
    ]
  }
}
The project's one tracking plan object. Names are examples.

usercurrent.config.ts can declare a snapshot for scalar properties, but its event format has no array properties and no affinity, so declare catalog events in the tracking plan object as above.

Publish the plan and the type together

Create and publish
# 1. Save the two objects as drafts (the MCP tools create_draft and update_draft do the same).
#    A project has one tracking plan: if it already has one, read it with get_object and add these events to it.
curl -X POST "$CONTROL/api/v1/projects/$PROJECT/objects" \
  -H "Authorization: Bearer $USERCURRENT_TOKEN" -H "Content-Type: application/json" -d @tracking-plan.json
curl -X POST "$CONTROL/api/v1/projects/$PROJECT/objects" \
  -H "Authorization: Bearer $USERCURRENT_TOKEN" -H "Content-Type: application/json" -d @product.item-type.json

# 2. Publish them together. Publishing the item type alone fails: its events need their snapshots in the same version.
curl -X POST "$CONTROL/api/v1/projects/$PROJECT/environments/dev/publish" \
  -H "Authorization: Bearer $USERCURRENT_TOKEN" -H "Content-Type: application/json" -d '{ "message": "Product catalog" }'

Item types and the tracking plan are created and published through the control API or the MCP server (create_draft or update_draft, then publish). Item types are not keys of usercurrent.config.ts yet, so uc publish does not carry them. Publishing to Production from an agent key waits for a person.

2. Upsert API

Send items from your server

PUT /v1/catalog/{type} on the same endpoint as events, with the public key and a secret key. Secret keys stay on your server.

PUT /v1/catalog/productExample
curl -X PUT "https://events.example.com/v1/catalog/product" \
  -H "x-usercurrent-key: $UC_PUBLIC_KEY" \
  -H "Authorization: Bearer $UC_SECRET_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "items": [
    { "sku": "trail-100", "title": "Trail 100 pack", "url": "https://shop.example.com/p/trail-100",
      "price": 129, "category": ["packs"], "brand": "Alder", "inStock": true },
    { "sku": "trail-200", "inStock": false }
  ] }'

# { "changed": 2, "unchanged": 0, "errors": [], "historyPending": false }

Each item is a flat object that names its id field. A field you leave out keeps its stored value, a field you send replaces it, and null removes a certified field (an observed value, if there is one, shows again). An item that changes nothing writes nothing, so sending the whole catalog every night costs reads rather than writes. The answer counts changed and unchanged items and lists invalid items by their index in the call; a call is not rolled back for them. historyPending: true means the change is stored and its history is still on its way to your lake.

Statuses: 400 for a body that is not JSON or not { "items": [...] }, 401 for a missing or wrong secret key, 404 for a type that is not published, 409 when the type or project is at its item limit, 413 for a call over the byte limit, 429 and 503 as in the limits below.

Retire and delete

Retire and delete
# Retire: stop serving, keep history. Delete: remove the item and its history. Up to 1,000 ids a call.
curl -X POST   "https://events.example.com/v1/catalog/product/retire" -H "x-usercurrent-key: $UC_PUBLIC_KEY" -H "Authorization: Bearer $UC_SECRET_KEY" -d '{ "ids": ["trail-100"] }'
curl -X DELETE "https://events.example.com/v1/catalog/product"        -H "x-usercurrent-key: $UC_PUBLIC_KEY" -H "Authorization: Bearer $UC_SECRET_KEY" -d '{ "ids": ["trail-100"] }'

Retiring stops an item being picked and keeps its history. Upserting a retired item again makes it active, and its certifying source becomes the one that upserted it. Deleting removes the item and its history.

Limits

Item id
A non-empty keyword of at most 256 bytes. The id field must not be personal data.
Field value, item record
4 KB per field value and 32 KB per item.
Item types, fields
50 item types per project, 200 fields per type.
Items
1,000,000 per item type and 10,000,000 per project, certified and observed together. A certified upsert past the limit is refused.
Upsert call
1,000 items. The runtime API takes 5 MB; the control API, the MCP tool and uc catalog upsert take 256 KB (the CLI splits a file to fit). A call that is too large is refused whole; one invalid item is reported by index and the rest apply.
Upsert rate
20 calls a second per item type. Past it the answer is 429 with Retry-After: 1.
History backlog
If history cannot reach the lake and the backlog fills, a change that would alter an item answers 503 with Retry-After: 60. Retry the call.
Import file
5 GB and 10,000,000 rows, and at most 1,000,000 distinct ids. One record is at most 256 KiB.
Imports at once
One per item type. Later ones queue in the order they started.

3. Bulk import

Load a whole feed from a file

Use an import for more than a few thousand items, or when a nightly export is the source. The file goes to your project's storage and a workflow validates it, then applies it.

File formats

NDJSON: one JSON object per line, in the same shape as an upsert item. CSV: a header row, then one item per row. The header must hold the id field, may not repeat a column, and may name only declared fields. Text, keyword and url columns are read as written; number, boolean, time, keywordList and money columns are read as JSON, so a list is ["a","b"] and money is {"amount":10,"currency":"USD"}, quoted as CSV needs. An empty cell means the field is not given. The JSON value null in a number, boolean, time, keywordList or money cell removes that field, as null does in an upsert; text, keyword and url cells cannot be removed from a CSV. A UTF-8 byte order mark is accepted. A file whose bytes are not valid UTF-8 fails.

products.ndjsonExample
{"sku":"trail-100","title":"Trail 100 pack","url":"https://shop.example.com/p/trail-100","price":129,"category":["packs"],"brand":"Alder","inStock":true}
{"sku":"trail-200","title":"Trail 200 pack","url":"https://shop.example.com/p/trail-200","price":179,"category":["packs","hiking"],"brand":"Alder","inStock":false}
products.csvExample
sku,title,url,price,category,brand,inStock
trail-100,Trail 100 pack,https://shop.example.com/p/trail-100,129,"[""packs""]",Alder,true
trail-200,Trail 200 pack,https://shop.example.com/p/trail-200,179,"[""packs"",""hiking""]",Alder,false

Full and incremental

Every import names a feed (letters, digits, underscores and hyphens, up to 128) and a mode. An incremental import only upserts the rows it holds. A full import also retires the items its feed supplied before and its file does not hold. It only retires items that this feed last certified, so items from the API, a different feed or a connector are never touched, and an item written after the sync started is kept. Use full only with a file that is the whole feed.

The retire guard

A full sync that would retire more than 10% of the feed's items and more than 100 items stops before retiring anything and waits in awaiting_confirmation. A truncated export therefore retires nothing. A person looks at the counts (missing of feedItems) in the console or the status and either confirms, which retires them, or discards, which retires nothing. Updates the import already applied stay in both cases. Below the guard a full sync retires its missing items without asking.

From the CLI

uc catalog import
export USERCURRENT_URL=https://console.example.com
export USERCURRENT_PROJECT=prj_...
export USERCURRENT_TOKEN=...            # a personal or agent key, never an argument

uc catalog import --environment dev --type product --file products.ndjson --feed shop-feed --mode full
uc catalog import-status --environment dev --type product --id <import id>
uc catalog confirm       --environment dev --type product --id <import id>   # only when the status says awaiting_confirmation
uc catalog discard       --environment dev --type product --id <import id>

From any HTTP client

Create, upload, start, follow
# 1. Declare the file. bytes is its exact length.
curl -X POST "https://events.example.com/v1/catalog/product/imports" \
  -H "x-usercurrent-key: $UC_PUBLIC_KEY" -H "Authorization: Bearer $UC_SECRET_KEY" \
  -d '{ "feed": "shop-feed", "format": "ndjson", "mode": "full", "bytes": 48213 }'
# { "id": "...", "phase": "uploading", "upload": { "url": "...", "headers": { ... }, "expiresIn": 3600 } }

# 2. PUT the file once, within the hour, with exactly the headers given. The object is create-only.
curl -X PUT "<upload.url>" -H "content-length: 48213" -H "content-type: application/x-ndjson" -H "if-none-match: *" --data-binary @products.ndjson

# 3. Queue it, then read its status until phase is completed, awaiting_confirmation or failed.
curl -X POST "https://events.example.com/v1/catalog/product/imports/$ID/start"   -H "x-usercurrent-key: ..." -H "Authorization: Bearer ..."
curl         "https://events.example.com/v1/catalog/product/imports/$ID"         -H "x-usercurrent-key: ..." -H "Authorization: Bearer ..."
# /confirm and /discard are POSTs on the same path.

The control API has the same imports under /api/v1/projects/{projectId}/environments/{environment}/catalog/{type}/imports, which is what the CLI, the MCP tools and the console call.

Phases

uploading
Created; the file has not been started.
queued
Started and waiting for the imports before it.
validating
Every row is checked before any is applied. A bad row fails the import with nothing applied.
applying
Rows are upserted in steps of up to 5,000. A failure partway leaves the earlier steps applied.
checking
A full sync counts the feed items the file did not hold.
awaiting_confirmation
The retire guard stopped the sync. Nothing is retired until a person confirms or discards.
retiring
Retiring the missing items.
completed, discarded, failed
Done. failed carries an error code; discarded retired nothing.

Rows that appear twice in one file count as duplicates in the status, and the last one wins. A rejected step retries; if it keeps failing the import fails with its error. If the lake is unavailable an import keeps applying until the history backlog fills.

4. CLI, MCP and console

The same operations in a terminal, an agent and the console

All three call the control API, so the key, the scopes and the audit trail are the same. An upsert and the actions of an import are audited.

uc catalog

Connection and keys are those of uc diff and uc publish. Every command needs --environment, and answers print as JSON.

uc catalog types
Item types with item counts, serving coverage and recent imports.
uc catalog coverage
Per type: items, serving coverage and imports awaiting confirmation.
uc catalog items --type T [--prefix P] [--after NEXT] [--limit N]
A page of items in id order, up to 100. Pass the next value a page prints as --after for the following page.
uc catalog item --type T --id ID
One item, certified and observed fields, and its versions from history.
uc catalog upsert --type T --file F
Upsert a JSON array or NDJSON file in calls of at most 1,000 items. Exits 1 when any item is reported invalid.
uc catalog import --type T --file F --feed NAME --mode full|incremental [--format ndjson|csv]
Create the import, upload the file, start it and print its status. The format defaults to csv for a .csv file and ndjson otherwise.
uc catalog import-status | confirm | discard --type T --id IMPORT
Read an import, or decide a full sync that is waiting.

MCP tools

Reads write nothing. Writes need a key that can write.

list_catalog_types, list_catalog_items, get_catalog_item
Read types with coverage, page through items, and read one item with its versions. Personal fields come back masked unless an owner or admin asks to see them.
get_catalog_stats, get_catalog_performance
Count a type's items by tier, eligibility and drift with each field's fill, and list its top items by views and clicks.
upsert_catalog_items
Upsert up to 1,000 items and 256 KB.
create_catalog_import, start_catalog_import, get_catalog_import
Get an upload URL, queue the uploaded file, follow it.
confirm_catalog_import, discard_catalog_import
Decide a full sync that is waiting. Confirm only when a person asked for it.

The console Catalog page

Under Personalize. Pick an item type to see its counts across every item: eligible now, certified, observed only, drifting, changed in the last 7 days, the serving set, and views and clicks over 7, 30 or 90 days. Search, sort and filter its items by any declared field; each shows its tier, whether it is eligible, drifting, in the serving set or held back, and its recent views. Open an item for its certified and observed fields, its views and clicks over the period, and its versions with the fields each one changed. The Schema tab shows how many items fill each field, and Serving shows how much of the eligible catalog the serving set holds and which dimension values it covers.

To load a feed, open Imports and drop an NDJSON or CSV file. The page checks the start of the file against the type's schema and shows the row count, how the columns map to fields and any rows that would fail; then it uploads the file and starts the import, and shows its phase until it finishes. A full sync that is waiting shows its counts with Retire and Keep them.

5. Observed items and drift

What your events say about items you have not sent

Events already name the items people view and click. The catalog learns from them without a lookup at collection time.

Events that declare a snapshot are grouped by item. A key becomes an observed item once at least 5 distinct visitors have touched it within 7 days; visitors classed as crawlers or agents never count, and a visitor with no classification does. A one-off id, a mistyped key or personal data typed into an item key therefore stays out. A new observed item shows in the console within the Lake flush interval, and a changed attribute on a known item is rewritten at most once a day. Each type keeps up to 8 observed attributes, the ones the snapshot declares.

Drift marks a certified item whose observed value for a field is unlike the certified one, or that has observed fields your feed lacks. It is how a stale feed or broken page tagging shows up: a price that events carry and your feed does not match, a category the feed never had. The console lists the drifting fields on the item.

Observed items are never returned by a slot. Whether a pick may name an item that exists only as observed is an open question.

6. Catalog slots

Recommend from the catalog

A slot whose affinity policy has a source takes its candidates from a catalog instead of a list of variants.

picked_for_you.slot.jsonExample
{
  "type": "slot",
  "key": "picked_for_you",
  "name": "Picked for you",
  "draft": {
    "v": 0,
    "variants": [{ "id": "default", "props": {} }],
    "fallback": "default",
    "rules": [],
    "policy": {
      "affinity": {
        "dims": ["category", "brand"],
        "source": {
          "catalog": "product",
          "filter": [
            { "field": "price", "op": "lt", "value": 200 },
            { "field": "id", "op": "ne", "context": "item.id" }
          ],
          "k": 4
        },
        "viewed": { "event": "product_view", "item": "id" },
        "goal": { "event": "add_to_cart", "windowHours": 24 }
      }
    }
  }
}
A slot object for create_draft or POST /objects, then publish. Names are examples.

source.catalog is the item type, filter narrows the serving set, and k is how many items to return. dims names the type's dimension fields the visitor's interests are learned on; each must also be an affinity dimension of the events that teach it (the tracking plan above). The policy ranks matching items by what the visitor has shown interest in and what the item is worth (its value field), and learns from the goal event. candidates and value are not given on a catalog slot, and viewed.item is id, so items the visitor already viewed sort lower.

A decision returns the picked items in items, each with its id and serve fields, and logs one exposure per item. Render items where you render a slot's props. With no profile to read (no consent, a held-out visitor) the slot still answers from the items' own value, and when it cannot answer at all it returns its default variant, with reason catalog_empty, catalog_unavailable or catalog_limit.

Filters

eq, ne
Compare with a constant or a request value. A dimension holds when any of the item's values does; for ne when none does.
in, nin
Up to 100 constants.
lt, lte, gt, gte
For number fields.
field: "id"
The item id, for leaving out the item on the page.
context
A value from the request instead of a constant: a context field, or item.<name> for a value your page names for its item (passed as item in the decide request).

A field keeps at most 4,096 distinct values in the serving set. Rarer values are grouped as "other", and a filter on one of them matches nothing; the console warns when that happens.

Serving limits

k
Up to 10 items returned. An item is returned by one slot per decision.
Filter
Up to 8 conditions, all of which must hold.
Serving set
Up to 20,000 items or 8 MB per item type, picked from the whole catalog. The console shows how much of the eligible catalog a type serves.
Catalog slots per decision
The first 4 catalog slots reached are scanned. A later one answers its default with reason catalog_limit.
Cold locations
Right after a publish, or in a location that has not served the type for a while, the first decision for a catalog slot can answer its default with reason catalog_unavailable while the index loads. Later decisions return picks.
Fewer matches than k
The slot returns what matches. With none, it returns its default.
Build interval
New and changed certified items reach decisions at the next index build: every 5 minutes by default, 1 to 60 per item type (buildMinutes). Items made unavailable, retired or deleted are held back sooner. Popularity and which items the serving set holds refresh hourly.

7. Reports and segments

Analyze and segment on item fields

Because events carry a snapshot of the item, reports and segments can use an item&apos;s fields.

Item reports are queries of kind items (run with run_query, saved as insights, or placed on dashboards as tables). They cover top items, engagement, funnel (the same person on the same item), click_through (per picked item, matching clicks to exposures by person, item and decision id) and versions (one item's history, newest 50 first). Group by any field with field: a field named in the event's snapshot is read from the event, and any other field is joined from the item's history as it was when the event happened. The query carries the item type and snapshot declarations, which must match what is published.

analytics.1 items queryShape
{
  "v": "analytics.1",
  "kind": "items",
  "report": "top",
  "event": { "event": "product_view" },
  "field": "brand",
  "catalog": { ...the published item type, with its id... },
  "snapshots": { ...the snapshot declarations of the events used... }
}

Item-field segments add an item and property to an event condition. Validation names an unknown type or field and refuses a field the event's snapshot does not declare.

alder_browsers.segment.jsonExample
{
  "type": "segment",
  "key": "alder_browsers",
  "name": "Looked at Alder products",
  "draft": {
    "v": 0,
    "condition": {
      "kind": "event",
      "event": "product_view",
      "window": { "value": 30, "unit": "days" },
      "count": { "op": "gte", "value": 3 },
      "where": [{ "item": "product", "property": "brand", "op": "eq", "value": "Alder" }]
    }
  }
}
An event condition with an item-field filter; brand is in product_view's snapshot above.

8. Privacy

Item fields are not a place for personal data

Item fields describe things, not people, and the platform enforces that where it can.

  • A field may carry privacy with a piiClass of none, pii or prohibited. A prohibited field is refused on every upsert and import and never enters the catalog.
  • A personal field (pii) cannot have a role, so it is never scored, filtered on, or returned with a pick, and the id field must be a non-personal keyword. Snapshot attributes must be declared and non-personal.
  • Reports and segments refuse restricted fields, and a query must match the published declaration, so a query cannot widen what the published privacy settings allow.
  • Only certified fields are returned to visitors, and only the serve fields, cut to 1 KB.
  • Observed items need 5 distinct people before they exist, so typing something personal into an item key once does not create one.
  • Catalog writes always need a secret key, even where event collection is in a looser mode, and they are audited.

Hand your agent the catalog

The usercurrent-catalog skill walks an agent through the item type, the first upsert, an import and a catalog slot.