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.
Declaring one
Section titled “Declaring one”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.
Field types
Section titled “Field types”| 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.
Descriptions
Section titled “Descriptions”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.
Keep it small
Section titled “Keep it small”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.
Promoting what emerged
Section titled “Promoting what emerged”The better path for most teams: write documents and untyped records for a few weeks, then look at what shape your data actually took.
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:
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.
Evolving
Section titled “Evolving”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.
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.jsonschema-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.
Review declarations
Section titled “Review declarations”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:
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
writemay add a view. Changing or removing one needsadmin, because it changes what every reader of that view is served.
Bringing a model you already have
Section titled “Bringing a model you already have”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.
# 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.