Skip to content
Beta. This surface may change before GA; breaking changes are dated in the changelog.

Custom schemas

Custom schemas are for when you know your ontology and want to govern it. If you do not yet, stay on the default nicia-base and write records without a kind — no data is lost while you decide.

Terminal window
curl -X PUT "$API/v1/brains/tenant:acme" \
-H "authorization: Bearer $NICIA_KEY" \
-H "content-type: application/json" \
-d '{
"name": "Acme",
"schema": {
"extends": "nicia-base",
"kinds": [
{
"name": "deployment",
"description": "A release of our software into one environment.",
"fields": [
{ "name": "environment", "type": "enum", "values": ["dev", "staging", "prod"],
"description": "Where it went." },
{ "name": "released_at", "type": "datetime", "description": "When it went out." },
{ "name": "version", "type": "string" },
{ "name": "rollback", "type": "boolean" }
]
}
],
"links": [
{ "name": "deployed_by", "from": ["deployment"], "to": ["person"] },
{ "name": "affects", "from": ["deployment"], "to": ["concept"] }
]
}
}'

extends: "nicia-base" keeps person, note, and the rest of the default vocabulary available. extends accepts either a built-in slug or a schema your own organization has registered (POST /v1/schemas), so one org-wide baseline can be extended per-Brain — the shape an AI-agent company with a standardized customer-brain schema actually wants. On the merge, the base wins a name collision: a kind or link you declare with the same name as one already on the base is dropped in favor of the base’s own definition, so re-declaring person cannot weaken the base’s shipped or registered shape. Naming a slug that is neither built-in nor registered for your organization is a 400 validation. Omit extends only if you want a closed vocabulary built from nothing but your own kinds — and know what that costs. A record written with no kind is filed under the schema’s free-form kind: whichever kind is named note, doc, or page, and otherwise the schema’s only fieldless kind. A closed vocabulary in which every kind declares fields has neither, so a kind-less write there is a 400 validation naming the kinds it does declare. Extending a base keeps the slot — nicia-base and nicia-core both declare note.

Type Notes
string The default. Free text. Declare a date or an instant as date or datetime instead.
number Integers and decimals.
boolean
enum Requires values. Widening later is safe.
string_list An array of strings.
date A calendar day, YYYY-MM-DD. May declare notifyBefore.
datetime An instant in ISO 8601 with its offset, stored in UTC. May declare notifyBefore.

Every field is optional. A record may carry any subset.

Declare a timestamp as date or datetime, not string. A string holds any text, so nothing validates it and a range query cannot compare it, and changing a field’s type later is refused. Dates and instants covers the accepted formats and notifyBefore reminders.

description is documentation, for the people and the models you point at this vocabulary. Write it as the instruction you would give an extractor, because that is where it earns its keep: a schema you declare here is the same shape you hand your own model as its output schema, and “The deployment target environment; prod means customer-facing” produces better extraction than “Where it went”.

Nicia’s own extraction of a source snapshot does read it: a model reads each document into the kinds and fields you declared, using their descriptions, and the plan shows each value with its quote before anything lands. Extraction on POST /documents is different: it writes statement records against a fixed vocabulary and never sees your declared kinds. Typed records of a kind you declared otherwise enter through a write you make. Onboarding: documents to a structured Brain is that pipeline end to end.

The strongest predictor of good extraction is a small vocabulary. A handful of kinds with a handful of fields each beats a sprawling ontology, because every additional kind is another decision the extractor can get wrong.

Start with the two or three kinds whose meaning you need governed and returned. Add more when the vocabulary needs them.

The better path for most teams: write documents and untyped records for a few weeks, then look at what shape your data actually took.

Terminal window
curl "$API/v1/brains/tenant:acme/schema/candidates" \
-H "authorization: Bearer $NICIA_KEY"
{
"data": {
"snapshot": {
"stateVersion": "generation_01K…:kgv_01K…",
"schemaVersion": "sha256:7cb…",
"observedAt": "2026-08-31T16:20:00.000Z"
},
"population": {
"records": 1958,
"kinds": [
{
"kind": "organization",
"records": 1958,
"fields": [{ "name": "name", "present": 1958, "coverage": 1 }]
}
]
},
"suggestions": [
{
"id": "organization.health_score",
"kind": "organization",
"field": { "name": "health_score", "type": "number" },
"occurrences": 1840,
"coverage": 0.94,
"reason": "Recurring undeclared key observed on 1840 organization records."
}
],
"violations": [],
"limits": { "analyzedRecords": 1958, "truncated": false, "minimumOccurrences": 2 }
}
}

coverage is the fraction of candidate records that carried the field — a low number means it is not really part of the shape. A suggestion’s type is what the values support: date for calendar days (2026-09-01), datetime for instants with an offset, and enum only for a small set of short labels that each recur across at least six records — an enum refuses every value it does not list, so anything less certain stays a string. violations[] is reserved for values that are present on a declared field but cannot satisfy its type or enum; an undeclared key is preserved and suggested separately, never called a violation. Promote what looks right:

Terminal window
curl -X POST "$API/v1/brains/tenant:acme/schema/promote" \
-H "authorization: Bearer $NICIA_KEY" \
-H "content-type: application/json" \
-H "idempotency-key: promote-health-score-v1" \
-d '{
"baseDeclaredVersion": "sha256:7cb…",
"snapshot": "generation_01K…:kgv_01K…",
"candidateIds": ["organization.health_score"]
}'

The response names the staged change. Accepting it adds the already-preserved value to the governed vocabulary; nothing is re-extracted or discarded. A stale schema or Brain snapshot is a 409, so the proposal can never silently describe different data from the analysis you inspected.

First call GET /v1/brains/{handle}/schema. Its schema is the complete governed DeclaredSchemaContract, declaredVersion is the proposal base, and materialization is either ready or an explicit drifted report. The read is diagnostic only: it never repairs state.

Terminal window
curl -X PATCH "$API/v1/brains/tenant:acme/schema" \
-H "authorization: Bearer $NICIA_KEY" \
-H "content-type: application/json" \
-H "idempotency-key: deployment-duration-v1" \
--data @schema-proposal.json

schema-proposal.json contains { "baseDeclaredVersion": "…", "schema": { …complete DeclaredSchemaContract… } }. It becomes one evolve_schema change carrying that complete contract, and the Brain’s admission policy decides it: an additive change is admitted and lands, and the answer’s declaredVersion is the version the Brain then holds — the contract you sent, exactly. Anything the policy holds is review_required and names its change. Either way it does not settle successfully and then leave the new field unavailable while another job catches up.

Change Result
Add a kind Admission decides (additive)
Add a field Admission decides (additive)
Widen an enum Admission decides (additive)
Add a link Admission decides (additive)
Tighten a review declaration Admission decides (additive)
Loosen a review declaration Destructive: a Brain key’s change waits
Narrow an enum 409 conflict
Change a field’s type 409 conflict
Remove a kind holding records 409 conflict

Nicia will not silently discard data you already wrote. When you genuinely want a breaking change, add the new kind alongside, migrate, then remove the old one.

Two field declarations decide what a person is asked about, and both travel in the contract — PATCH /schema and an organization schema carry them; the inline create shape carries multiplicity only:

  • consequence: "review" (on a field, or on a kind) — an agent’s substantive change to the field waits in review at the default rung, even when nothing contradicts it.
  • multiplicity: "single" — the field holds one current value, so a source stating a different one contradicts and waits in review. Undeclared, a difference is only a disagreement; "multiple" makes it another value.

Adding either, or raising it, asks for more review and is ordinary evolution. Removing or lowering either — review to nothing, single toward multiple — means changes that used to wait would land, so it is admitted like a destructive change: from a Brain key it waits in review; the management key is a person, whose schema changes land. A Brain key with write or admin may submit a change, so an agent that needs a kind can propose it — over MCP with brain_evolve_schema — and the Brain’s mode decides, as it does for record writes: an additive change lands on an open Brain and waits in review on a reviewed one. A key without admin may only add: new kinds, new fields, and new links, with whatever notifyBefore offsets they declare for themselves. Changing anything already declared — an enum’s values, an existing field’s or kind’s safeguards — is refused with 403 for that key, because an open Brain would admit it at once and it changes how every other writer’s records are governed. The same key goes one step further and cannot turn on consequence: "review" or consequenceBy even on a new kind or field, over HTTP or over MCP (brain_evolve_schema): what a person is asked about is the owner’s call, and a marker an agent declared would make that agent’s own later updates wait in review. An admin key may read GET /schema to get the declaredVersion it evolves from; it still reads none of the Brain’s records.

Records written before a field existed keep their values. Undeclared keys round-trip on the way in and become governed and returned when you declare them — so promoting a field is retroactive, not forward-only. Its index is rebuilt from the records you already have, so it becomes filterable too — see when a declaration changes.

One Brain often serves several purposes that disagree about the same record. Finance means a company’s legal name and trusts the ERP’s revenue; marketing means the brand name. A view is a named, read-only way of reading the Brain, declared in the schema beside the kinds it reads. It has four parts:

  • bindings — per kind, the declared field an everyday term means in this view.
  • prefer — whose word wins when producers disagree, most preferred first. A producer is named by kind (human, agent, system) or by source id (from a field’s look-behind; a trailing * matches a prefix). A write made with a Brain key is attributed to the key’s id, not its name (the name is only the label shown), so two keys with the same name are different producers. Read the source id from look-behind rather than guessing it.
  • fields — per kind, the fields the view shows.
  • kinds — the kinds the view shows.

Declare views on the call that creates the Brain, or add them later with PATCH /schema:

{
"name": "Accounts",
"schema": {
"kinds": [
{
"name": "company",
"fields": [
{ "name": "legal_name", "type": "string" },
{ "name": "brand_name", "type": "string" },
{ "name": "revenue", "type": "number" }
]
}
],
"views": [
{
"name": "finance",
"bindings": { "company": { "name": "legal_name" } },
"prefer": [{ "source": "internal:agent:erp-sync" }]
},
{
"name": "marketing",
"fields": { "company": ["brand_name"] },
"bindings": { "company": { "name": "brand_name" } }
}
]
}
}

Read a record through a view:

Terminal window
curl "$API/v1/brains/tenant:acme/views/finance/records/acme" \
-H "authorization: Bearer $NICIA_KEY"
{
"data": {
"record": {
"id": "acme",
"kind": "company",
"fields": {
"legal_name": "Acme Holdings Ltd",
"brand_name": "Acme",
"revenue": 90,
"name": "Acme Holdings Ltd"
}
},
"view": { "name": "finance", "schemaVersion": "7f3c2a91d4e8b0c6" },
"receipt": "rcp_019fff53"
}
}

A view starts from the record the Brain accepted and changes only what it declares. A field a preferred producer states is served from that producer’s latest word; every other field is the accepted value. Here the CRM’s later revenue of 110 is still what GET /records/acme serves.

The receipt pins two things: the record version the view was read from, and the view name and schema version it was read under. Add ?view=finance to a field’s look-behind to see which field a term is bound to, and whose word the view serves.

Rules for views:

  • Views are computed when read. You cannot filter or count a view yet.
  • A view’s references are checked. A view naming an undeclared kind or field is refused when declared.
  • Changing a view is governed like any other declaration. A key with write may add a view. Changing or removing one needs admin, because it changes what every reader of that view is served.

If your domain model mostly works and you want it here for the governance, versioning, and provenance rather than because our shape is better, declare only the parts whose meaning you want governed and returned today and send everything else anyway.

Terminal window
# Declare the three kinds you govern. Send twenty fields per record.
curl -X PUT "$API/v1/brains/tenant:acme" \
-H "authorization: Bearer $NICIA_KEY" \
-H "content-type: application/json" \
-d '{ "name": "Acme", "schema": { "extends": "nicia-base",
"kinds": [{ "name": "account", "fields": [
{ "name": "arr", "type": "number" },
{ "name": "segment", "type": "enum", "values": ["smb", "mid", "ent"] }
]}] } }'

The seventeen fields you did not declare still arrive, still round-trip, and come back in fields on every record you read; each write that states one names it in undeclared, so you always know which are outside the governed vocabulary. There is no re-import, and no moment where your model has to be finished before it is useful. /query filters on the declared fields; the undeclared ones come back but are not queryable. What declaring needs is a create call: declare the parts you are sure of here, and if you expect the list to keep growing across a fleet, name an organization schema by slug instead of declaring inline — a slug-named Brain is the only kind a later rollout can reach.

That is the point of the lossless guarantee: an incomplete model is a valid starting state, not a migration you have to finish before you begin.

  • Schemas — the tiers and the default.
  • Records — writing against your vocabulary.