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.
The one principle to internalize
Section titled “The one principle to internalize”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.
Concepts
Section titled “Concepts”| Term | What it is |
|---|---|
| Collection | A named table of records you create on demand — e.g. products, partners. You decide what exists. |
| Record | One row, identified by its external_id (its id in the source system). |
| Ingest URL | A webhook URL with the credential baked in — the random part is the password. Your source posts records to it. |
| Envelope | The stable response shape every query returns (see Querying). |
Quickstart
Section titled “Quickstart”1. Create a collection
Section titled “1. Create a collection”From an agent over MCP:
create_collection(name="products", description="Odoo product catalog")or over HTTP with a workspace bearer token:
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 secret — shown 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."}2. Import the records you already have
Section titled “2. Import the records you already have”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.
4. Query it
Section titled “4. Query it”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.
Ingest
Section titled “Ingest”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
reconcileare bearer-only for exactly this reason.
Upsert a record
Section titled “Upsert a record”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
id→external_id"4412". Re-posting the same id updates it (idempotent). Ids are canonicalized, so integer4412,"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_datethan what’s stored is ignored.
Import your existing records first
Section titled “Import your existing records first”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:
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.
Backfill (batch, via the ingest URL)
Section titled “Backfill (batch, via the ingest URL)”The producer-side equivalent — post an array to the ingest URL (up to 500 records per call):
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:
curl -X POST https://api.blenau.com/ingest/<secret>/embed-pending# → { "embedded": 500, "more": true } ← call againDeletes & reconciliation
Section titled “Deletes & reconciliation”Deletes and reconciliation are bearer-authenticated (not the ingest URL) — a URL holder must not be able to erase your data.
# Tombstone one recordcurl -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"] }'Lost your ingest URL? Regenerate it
Section titled “Lost your ingest URL? Regenerate it”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.
Guardrails
Section titled “Guardrails”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).
Querying
Section titled “Querying”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"}Trust the confidence, not the raw score
Section titled “Trust the confidence, not the raw score”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.
Exact identifier match
Section titled “Exact identifier match”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.
Structured filters
Section titled “Structured filters”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.
Discovery
Section titled “Discovery”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”.
Field roles
Section titled “Field roles”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 draft, not a schema
Section titled “A draft, not a schema”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.
| Role | What it does | Example |
|---|---|---|
Semantic (embed_fields) | Feeds the embedding — what “find me a comfortable oak dining table” matches against | name, description, categ_id |
Filterable (field_types) | Exact conditions and ranges, compared with the right type | list_price → number, date_order → date, state → enum |
Identifier (id_match_fields) | Matched exactly; a query that is this value returns that record with full confidence | default_code, barcode |
Returned (exposed_fields) | Allowlist of attributes sent back with a result | name, list_price |
Control (id_field, write_date_field) | Which keys carry the record id and the update clock | id, 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.
Why the type matters more than it looks
Section titled “Why the type matters more than it looks”Without a declared type, a filter compares text:
list_price < 80 → "9.99" < "80" → falseThat 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
dateorenum.
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 roles: reindex
Section titled “Changing roles: reindex”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-pendingDeclaring 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.
Tools & endpoints
Section titled “Tools & endpoints”MCP tools (the primary surface for agents):
| Tool | Purpose |
|---|---|
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 & path | Auth | Purpose |
|---|---|---|
POST /ingest/{secret} | secret in URL | Upsert one record |
POST /ingest/{secret}/batch | secret in URL | Upsert an array (≤500) |
POST /ingest/{secret}/embed-pending | secret in URL | Drain pending embeddings |
POST /collections | bearer | Create a collection |
POST /collections/{name}/import | bearer | Bulk-import records (no ingest secret needed) |
GET /collections | bearer | List collections |
GET /collections/{name}/describe | bearer | Discover shape |
POST /collections/{name}/query | bearer | Query → envelope |
GET /collections/{name}/records/{id} | bearer | Fetch one record |
DELETE /collections/{name} | bearer | Delete a collection |
DELETE /collections/{name}/records/{id} | bearer | Tombstone a record |
POST /collections/{name}/reconcile | bearer | Tombstone ids not in the live set |
POST /collections/{name}/rotate-secret | bearer | Rotate 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.
Access & isolation
Section titled “Access & isolation”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.
What’s not here (yet)
Section titled “What’s not here (yet)”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.