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": "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.

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.

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.

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. 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" \
-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.

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" \
--data @schema-proposal.json

schema-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.

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 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.

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