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": "string", "description": "ISO 8601 timestamp." }, { "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. Also used for ISO timestamps. |
number |
Integers and decimals. |
boolean |
|
enum |
Requires values. Widening later is safe. |
string_list |
An array of strings. |
Every field is optional. A record may carry any subset.
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”.
It is not read by Nicia’s own extraction. That pipeline writes records of one
fixed kind — statement — against a fixed vocabulary, and never sees your
declared kinds, fields, or descriptions; declared-kind extraction is not
shipped. Typed records of a kind you declared 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. 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" \ -d '{ "baseDeclaredVersion": "sha256:7cb…", "snapshot": "generation_01K…:kgv_01K…", "idempotencyKey": "promote-health-score-v1", "candidateIds": ["organization.health_score"] }'The response names a Review proposal. 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" \ --data @schema-proposal.jsonschema-proposal.json contains { "baseDeclaredVersion": "…", "idempotencyKey": "deployment-duration-v1", "schema": { …complete DeclaredSchemaContract… } }. The operation stages one evolve_schema Review
item. Acceptance is synchronous through verified materialization: it does not
settle successfully and then leave the new field unavailable while another job
catches up.
| Change | Result |
|---|---|
| Add a kind | Governed Review proposal |
| Add a field | Governed Review proposal |
| Widen an enum | Governed Review proposal |
| Add a link | Governed Review proposal |
| 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.
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. Non-slug field
predicates still return 400 index_not_ready until property indexes ship.
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 resolves exact slug equality today; every non-slug
predicate returns 400 index_not_ready, whether declared or not. 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.