Skip to content

Collections (ERP & structured data)

Collections let your agents answer questions over your structured business data — products, customers, orders — not just your documents. You sync records from a source system (an Odoo ERP to start, but anything that can POST JSON), and your agent asks in plain language:

“Do we carry Canon premium bed sheets?”

Blenau resolves that to the right record’s external_id + metadata, ranked by meaning and boosted by exact code matches, with a confidence verdict so a machine never acts on a shaky guess.

New here? Which one do I use? compares Collections, Knowledge and Notes side by side.

Blenau resolves; it does not hold live truth.

A collection is for turning a fuzzy question into the right record id. Volatile values — stock on hand, the price you’ll charge, an order’s current status — are not stored in Blenau. Your agent takes the external_id Blenau returns and fetches those live from the source system itself.

This split is deliberate: Blenau’s copy of a record is only as fresh as the last sync (great for finding the record and reasoning about stable attributes), while your ERP stays the single source of truth for anything that changes by the minute. Use Blenau’s attributes for context; use the live system for the number you commit to.

TermWhat it is
CollectionA named table of records you create on demand — e.g. products, partners. You decide what exists.
RecordOne row, identified by its external_id (its id in the source system).
Ingest URLA webhook URL with the credential baked in — the random part is the password. Your source posts records to it.
EnvelopeThe stable response shape every query returns (see Querying).

From an agent over MCP:

create_collection(name="products", description="Odoo product catalog")

or over HTTP with a workspace bearer token:

Terminal window
curl -X POST https://api.blenau.com/collections \
-H "Authorization: Bearer blenau_tk_xxx" \
-H "Content-Type: application/json" \
-d '{"name": "products", "description": "Odoo product catalog"}'

The response includes an ingest_url and a secretshown once. Store the secret; rotate it later if it leaks.

{
"id": "",
"name": "products",
"ingest_url": "https://api.blenau.com/ingest/<secret>",
"secret": "<secret>",
"note": "Shown once. Lost it? Regenerate anytime — your records are not affected."
}

Do this before wiring any webhook. A webhook only fires when a record changes, so it will never deliver your existing catalogue. Import once from the dashboard (Collections → your collection → Import records) or with POST /collections/{name}/import — see Import your existing records first.

3. Point your source at the ingest URL (to stay fresh)

Section titled “3. Point your source at the ingest URL (to stay fresh)”

Your source posts the flat JSON it already produces. For Odoo, an automation that fires on create/write can POST the record. No custom transformation is required on Blenau’s side — see Ingest.

query_collection(collection="products", query="canon premium bed sheets")
{
"results": [
{ "external_id": "4412", "relevance": 0.88, "match_type": "semantic",
"rendered": "Canon Premium Sateen Sheet 300TC — CN-PREM-300 (id 4412)",
"attributes": { "name": "Canon Premium Sateen Sheet 300TC", "category": "sheets" } }
],
"match_type": "semantic",
"confidence": "high"
}

Your agent takes external_id 4412 and asks Odoo for live stock/price.

Blenau accepts the flat JSON your source emits natively — the control fields never live in the body:

  • Which collection is decided by the secret in the URL, not a field in the JSON. This means a producer that can only configure a URL (like Odoo’s native webhook, which can’t always set custom headers) still authenticates.
  • Insert vs. update vs. delete is the HTTP method / route, not a field.
  • The record’s id and update clock are read from fields inside your JSON, named by the collection’s config. Defaults match Odoo: id_field = id, write_date_field = write_date.
  • Everything else becomes the record’s queryable attributes.

Is the ingest URL a webhook or a token? Both.

Section titled “Is the ingest URL a webhook or a token? Both.”

It is a webhook URL with the credential embedded in it — there is no separate API key, so the random segment is the password. That is deliberate: Odoo’s webhook action (like many no-code tools) lets you configure a URL but not an auth header, so the credential has to travel in the URL. Slack and GitHub incoming webhooks work the same way.

Two consequences worth internalizing:

  • Treat it like a password, not a link. Keep it out of chats, tickets and screenshots. If it is ever exposed, regenerate it.
  • Its power is deliberately narrow. It can only write to that one collection. It cannot read your data, cannot delete records, and cannot reach any other collection — deletes and reconcile are bearer-only for exactly this reason.
Terminal window
curl -X POST https://api.blenau.com/ingest/<secret> \
-H "Content-Type: application/json" \
-d '{ "id": 4412, "name": "Canon Premium Sateen Sheet 300TC",
"default_code": "CN-PREM-300", "list_price": 79.99,
"categ_id": [3, "Sheets"], "write_date": "2026-07-18T10:30:00Z" }'
  • The record is identified by idexternal_id "4412". Re-posting the same id updates it (idempotent). Ids are canonicalized, so integer 4412, "4412" and Make’s "4412.0" are the same record — never duplicates.
  • Blenau re-embeds only when the embedded text changes. A metadata-only update (e.g. a price change) skips the embedding step.
  • Odoo relational values like categ_id: [3, "Sheets"] are stored as-is; their labels feed search, their ids don’t clutter it.
  • Out-of-order delivery is handled: an update with an older write_date than what’s stored is ignored.

The fastest path is the dashboard: Collections → your collection → Import records — paste a JSON array (or upload a .json export) and it loads, embeds and becomes queryable immediately. No ingest secret needed; it uses your session.

Programmatically, the same thing with your bearer token:

Terminal window
curl -X POST https://api.blenau.com/collections/products/import \
-H "Authorization: Bearer blenau_tk_xxx" \
-H "Content-Type: application/json" \
-d '{"records":[{"id":1,"name":"…"},{"id":2,"name":"…"}]}'
# → {"imported": 2, "failed": 0, "embedded": 2, "results": [...]}

Loop it over your export in pages of ≤500. Re-importing is safe: records are keyed by id, so an existing record is updated, never duplicated.

The producer-side equivalent — post an array to the ingest URL (up to 500 records per call):

Terminal window
curl -X POST https://api.blenau.com/ingest/<secret>/batch \
-H "Content-Type: application/json" \
-d '{ "records": [ {"id": 1, "name": "…"}, {"id": 2, "name": "…"} ] }'

Each record is applied independently — one malformed record fails alone and the rest still land. Batch ingest does not embed inline; drain embeddings after a large import by calling until more is false:

Terminal window
curl -X POST https://api.blenau.com/ingest/<secret>/embed-pending
# → { "embedded": 500, "more": true } ← call again

Deletes and reconciliation are bearer-authenticated (not the ingest URL) — a URL holder must not be able to erase your data.

Terminal window
# Tombstone one record
curl -X DELETE https://api.blenau.com/collections/products/records/4412 \
-H "Authorization: Bearer blenau_tk_xxx"
# Reconcile: send the full set of live ids; Blenau tombstones anything missing.
# Catches deletes your webhook dropped. An empty set is refused (it would wipe
# the collection).
curl -X POST https://api.blenau.com/collections/products/reconcile \
-H "Authorization: Bearer blenau_tk_xxx" \
-H "Content-Type: application/json" \
-d '{ "live_ids": ["1","2","4412"] }'

The ingest URL is shown once — Blenau stores only a hash of it, so it can never be displayed again. That is deliberate (it is the credential), but it does not mean a lost URL costs you the collection:

Dashboard → Collections → your collection → “Regenerate ingest URL”, or POST /collections/{name}/rotate-secret, or the regenerate_ingest_url MCP tool.

You get a new URL (shown once). The previous one stops working immediately, so update it wherever you configured it (Odoo automation, Make scenario, scripts). Your records are not affected — regenerating changes the door, never the data behind it. Use the same action if the URL ever leaks.

Ingest payloads are bounded: 512 KB per body, 500 fields, 8 levels of nesting, 500 records per batch. Rotate a leaked secret with POST /collections/{name}/rotate-secret (the old one is instantly revoked).

Every query returns the same stable envelope, on every surface (MCP, API):

{
"results": [
{
"external_id": "4412", // the handle for live lookups (always a string)
"relevance": 0.88, // 1 - cosine distance
"match_type": "exact_id | semantic | filter",
"rendered": "…one readable line, id always present…",
"attributes": { } // subject to the collection's field allowlist
}
],
"match_type": "exact_id | semantic | filter",
"confidence": "high | low | none"
}

confidence is Blenau’s verdict, computed server-side from the top result’s strength and its lead over the runner-up:

  • high — a strong, unambiguous match. Safe to act on the top result.
  • low — a plausible but ambiguous match. Disambiguate before acting (ask a clarifying question, or show options).
  • none — no usable match. Don’t invent one.

A cosine score alone can’t tell a machine “this is a toss-up between two similar products.” The verdict can — use it.

If the query is an identifier — an external_id, or a value of a field you’ve marked as an identifier (SKU, reference, barcode) — Blenau returns that record directly with match_type: "exact_id" and confidence: "high". Pure semantic search fumbles exact codes; this makes id resolution reliable.

Narrow results with a structured filter over the collection’s fields — equality or ranges. Filters are typed (via the fields you’ve declared), so numbers compare numerically, not lexically:

query_collection(
collection="products",
query="waterproof jacket",
filters={ "category": "outerwear", "list_price": { "lte": 120 } }
)

Pass an empty query with filters for a pure filter — “every product in this category” — with no semantic step. Call describe_collection first to learn which fields are filterable and their types.

Before an agent builds a query, it should learn the collection’s shape:

describe_collection(name="products")
{
"name": "products",
"id_field": "id",
"id_match_fields": ["default_code"],
"filterable_fields": { "category": "string", "list_price": "number" },
"exposed_fields": ["name", "category", "list_price"],
"records": 12840,
"pending_embed": 0,
"failed_embed": 0,
"last_ingest_at": "2026-07-19T21:04:11Z"
}

filterable_fields is the map an agent needs to construct correct filters; records / pending_embed tell you the sync is complete.

last_ingest_at is your sync health check. null means data has never arrived — which is what a misconfigured webhook looks like (an empty collection is otherwise indistinguishable from a correctly-wired one that simply hasn’t seen a change yet). The dashboard surfaces this as “No data has arrived yet” vs “Last data received 3 minutes ago”.

Every field a producer sends plays one of a few roles, and getting them right is the single biggest lever on answer quality. Blenau infers a draft on your first sync and the dashboard shows it to you under Fields — that draft is meant to be corrected, not trusted blindly.

A collection has no fixed schema. Every field you send is stored as-is (in a single JSONB document per record), so you can send any fields, in any shape, whenever you like — nothing is frozen and a rough first payload breaks nothing. The roles you set here are a draft: a first guess Blenau makes from your first records, which you edit and re-run as your data evolves.

So when your data changes — you start sending a new field, or fix a payload that was missing one — you don’t start over:

  • New fields are stored immediately and show up under Detected in your data (the dashboard re-samples your live records every time you open Fields).
  • Hit Re-detect from my data to fold the current fields into the roles, then review and save. That’s the “treat my latest payload as the source of truth” button.
  • Changing which fields are Semantic is the only change that costs anything (it re-embeds) — and you’re always shown the price first.
RoleWhat it doesExample
Semantic (embed_fields)Feeds the embedding — what “find me a comfortable oak dining table” matches againstname, description, categ_id
Filterable (field_types)Exact conditions and ranges, compared with the right typelist_pricenumber, date_orderdate, stateenum
Identifier (id_match_fields)Matched exactly; a query that is this value returns that record with full confidencedefault_code, barcode
Returned (exposed_fields)Allowlist of attributes sent back with a resultname, list_price
Control (id_field, write_date_field)Which keys carry the record id and the update clockid, write_date

Semantic and filterable overlap freely — a category is often usefully both. Identifier is the one exclusive role: identifiers are resolved by exact equality on their own path and are deliberately kept out of the embedding, so listing one as semantic is rejected.

Without a declared type, a filter compares text:

list_price < 80 → "9.99" < "80" → false

That is not an error you will see; it is a wrong answer you will not notice. If a field arrives empty on the record Blenau first inferred from (Odoo sends false for every empty field), it never gets typed at all. The Fields section shows each field’s coverage — how often it actually carries a value — which is exactly the signal that explains this.

What is kept out of the embedding, and why

Section titled “What is kept out of the embedding, and why”

Not every text field carries meaning. A SKU, a timestamp and a state code are all strings, and all three are noise in a meaning-based search — they dilute the vector and push confident matches down into low. So the default embedding excludes, on top of the control fields:

  • every identifier (it has its own exact-match path);
  • every field typed date or enum.

enum is the useful one to declare by hand: it marks a closed set (done, draft, EUR, kg) as filter metadata rather than semantics. Blenau suggests it when a field’s values are few and repeat — ten records with ten different names are not an enum.

Setting embed_fields explicitly overrides all of this: your list is used verbatim, in your order.

Changing which fields are semantic changes the text behind every existing vector. Saving your config never re-embeds — you are asked first, with the price:

POST /collections/products/reindex # preview (dry_run defaults to true)
# → {"records": 12840, "would_reembed": 12840,
# "estimated_tokens": 1284000, "estimated_cost_usd": 0.0257, "applied": false}
POST /collections/products/reindex?dry_run=false
# → {"reembedding": 12840, ...} then drain with /embed-pending

Declaring your fields before a large import is free; changing them afterwards costs a reindex. If you already know your schema, the dashboard lets you fill them in on an empty collection.

Inspecting what your source actually sends

Section titled “Inspecting what your source actually sends”
collection_fields(name="products")
{
"sampled_records": 500,
"fields": [
{"name": "default_code", "suggested_type": "string", "suggested_role": "identifier",
"coverage": 1.0, "distinct_count": 64, "examples": ["CN-PREM-300"]},
{"name": "list_price", "suggested_type": "number", "suggested_role": "filterable",
"coverage": 1.0, "examples": [79.99]},
{"name": "state", "suggested_type": "enum", "suggested_role": "filterable",
"coverage": 1.0, "distinct_count": 3, "examples": ["done"]},
{"name": "barcode", "detected_type": null, "coverage": 0.0, "examples": []}
],
"configured": { "...": "what is set today" },
"unknown_fields": ["referencia_interna"]
}

coverage: 0.0 means your source sends that field empty on every record. unknown_fields lists configured names that were never observed — almost always a typo, and reported as a warning rather than an error, because a genuinely rare field may not appear in the sample.

MCP tools (the primary surface for agents):

ToolPurpose
list_collections()List your collections
describe_collection(name)Discover fields, types, counts
query_collection(collection, query, filters?, top_k?)Resolve a query → envelope
get_record(collection, external_id)Fetch one record
create_collection(name, description?)Create + mint the ingest URL
update_collection(name, …)Declare field roles (semantic / filterable / identifier / returned / control)
collection_fields(name)What the source actually sends vs what is configured
reindex_collection(name, dry_run?)Preview then apply a re-embed after a role change
regenerate_ingest_url(name)Get a new ingest URL (recovery / rotation)
delete_collection(name)Delete a collection and its records
delete_record(collection, external_id)Tombstone one record

HTTP API (for no-code tools like Make/Zapier/n8n and your own backends):

Method & pathAuthPurpose
POST /ingest/{secret}secret in URLUpsert one record
POST /ingest/{secret}/batchsecret in URLUpsert an array (≤500)
POST /ingest/{secret}/embed-pendingsecret in URLDrain pending embeddings
POST /collectionsbearerCreate a collection
POST /collections/{name}/importbearerBulk-import records (no ingest secret needed)
GET /collectionsbearerList collections
GET /collections/{name}/describebearerDiscover shape
POST /collections/{name}/querybearerQuery → envelope
GET /collections/{name}/records/{id}bearerFetch one record
DELETE /collections/{name}bearerDelete a collection
DELETE /collections/{name}/records/{id}bearerTombstone a record
POST /collections/{name}/reconcilebearerTombstone ids not in the live set
POST /collections/{name}/rotate-secretbearerRotate the ingest secret

Using Make/Zapier/n8n? Point an HTTP module at POST /collections/{name}/query with your bearer token — no MCP needed for a deterministic scenario.

Collections are workspace-scoped. A collection and its records are visible only within the workspace that owns them, enforced at the database level — the same isolation your documents get.

  • Reads (list / describe / query / get_record) are open to any member of the workspace.
  • Writes (create / delete / reconcile / rotate-secret) require a write-capable role (admin or member); readers and read-only agents are denied.

Which attributes a query returns is governed by the collection’s field allowlist, so sensitive columns (cost price, margin, supplier) never leak to a customer-facing agent that logs its context.

Collections v1 is read-only resolution: Blenau never writes back to your source system. The connector that produces the JSON (an Odoo module, a Make scenario) lives on your side — Blenau exposes the ingest, reconcile and embed-pending primitives it needs. If you’d like a turnkey Odoo connector, tell us.