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

Schemas

A schema is the vocabulary a Brain uses for structured data: which kinds of record exist, what fields they have, and how they link. It decides what you can govern and return. It never decides what you can store — nothing you send is ever dropped. Today, /query only resolves exact slug equality; every other field predicate returns 400 index_not_ready until property indexes ship.

You do not need one to start. What you do need to know is that a Brain’s schema is picked when the Brain is created — so every choice below is a create-time choice, and everything you send is stored either way.

Tier What you do Good for
None POST /documents and stop Pure retrieval. Prose in, grounded answers out.
Default Nothing — every Brain gets nicia-base Structured writes with zero authoring.
Untyped records PATCH /records/{id} with no kind You have ids and fields but no entity model yet.
Named schema "schema": "<slug>" One vocabulary shared across many Brains.
Custom "schema": { … } You know your ontology and want it governed.

Two guarantees make this a ramp rather than five doors:

  • The address never changes. A record is addressed by an id you choose, and kind is a property you attach later. Turning an untyped record into a typed organization does not move it, so nothing built against it breaks.
  • Nothing is lost on the way in. Every key you send is stored and round-trips, declared or not. A declared field governs and returns values that were already there — declaring is retroactive, never forward-only.

Together those mean a partial or nearly-complete model can come over as-is and the parts that do not fit our shape are kept rather than dropped. Nothing you send has to be re-imported to become useful later.

A Brain’s initial schema is settled on the call that creates the Brain. PUT /v1/brains/{handle} takes schema on the create; a later PUT on the same handle reconciles name, purpose, and mode, and one that names a different schema is refused with a 400 rather than silently retyping a Brain’s whole vocabulary. So this is the decision on this page worth making before you write anything: nicia-base declares no typed fields, nicia-core declares the ones in its table below, and a custom schema declares whatever you say.

Later evolution goes through PATCH /v1/brains/{handle}/schema or promotion of a snapshot candidate. Both create a governed Review proposal; neither mutates the contract inline. Declaring is retroactive when accepted — a field applies to values already written, and nothing is re-imported. The shipped way to declare one on Brains that already exist is a fleet operation rather than a per-Brain one: an organization schema, which can be committed forward and rolled out to the Brains on it.

GET /v1/brains/{handle}/schema is the source of truth before either operation: it returns the complete declared contract, its stable declaredVersion, and a separate ready | drifted materialization report without repairing anything.

If you have… Start at
A pile of markdown None — documents, and stop
Structured markdown you curated or an LLM organised Untyped records — preserve your ids and fields without choosing kinds
Prose plus some fields and ids from other systems Untyped records — add a kind only when you need exact queries
A vocabulary you already share across customers Named schema — register it once, name its slug per Brain
A domain model that mostly works Custom — declare what you need governed and returned today

Every Brain starts here unless you say otherwise. It classifies documents into kinds by folder and frontmatter type — it declares no typed fields of its own, so /query resolves exact slug equality only. If you already know the fields you want governed and returned, say so on the create call — nicia-core or a custom schema. Every value you send stores and round-trips either way; non-slug predicates return 400 index_not_ready.

Kind Holds
person Founders, operators, investors, contacts
company Companies and organizations
deal Investments and transactions
concept Reusable ideas, themes, and topics
note Free-form notes and documents

Links: works_at (personcompany), invested_in (person or companycompany or deal), founded (personcompany), advises (personperson or company), plus a document-to-document mentions link and a document-to-concept reference link, both inferred from wikilinks rather than declared frontmatter fields.

Terminal window
curl -X PATCH "$API/v1/brains/tenant:acme/records/renewal-call-mar-2026" \
-H "authorization: Bearer $NICIA_KEY" \
-H "content-type: application/json" \
-d '{ "fields": { "notes": "Renewal call, 2026-03-14" } }'

Most structured data does not fit a tidy entity, and most teams do not yet know what their entities are. A record written with no kind is filed under the schema’s free-form kind rather than under a kind you never chose — note on both built-ins, which is what it reads back as — and it is still a perfectly useful record: it has your id, it can carry fields, and it can be related to other records.

{ "id": "crm-account-4471", "fields": { "arr": 120000 } }

That is a complete, useful state. You never have to decide what something is in order to say something about it — and when you do decide, attaching the kind is one PATCH that leaves the address alone.

The free-form kind is whichever kind is named note, doc, or page, and otherwise the schema’s only fieldless kind. A vocabulary with neither has no slot to file an untyped record under, so a write that names no kind is a 400 validation listing the kinds it does declare. Both built-ins declare note, so this only bites a closed custom schema that omits extends.

nicia-base above carries the platform’s original investor-flavoured names (company, deal). If you want a domain-neutral vocabulary instead, select it explicitly at create time — it is not the default:

Terminal window
curl -X PUT "$API/v1/brains/tenant:acme" \
-H "authorization: Bearer $NICIA_KEY" \
-H "content-type: application/json" \
-d '{ "name": "Acme", "schema": "nicia-core" }'
Kind Fields Holds
person name, email, role, org Humans
organization name, domain, segment Companies, teams, accounts
topic name, summary Products, concepts, subjects
event title, occurredAt, category, summary Meetings, tickets, incidents, releases
note title Free-form prose

Links: about (any kind → topic), involves (eventperson or organization), works_at (personorganization). note is the free-form kind here too, so a record written with no kind lands on it exactly as it does under nicia-base.

Every field is optional. Send one field or all of them.

A schema your organization has registered is nameable by slug exactly like a built-in, so a fleet of same-shaped Brains has one vocabulary to maintain rather than one per Brain:

Terminal window
curl -X PUT "$API/v1/brains/tenant:acme" \
-H "authorization: Bearer $NICIA_KEY" \
-H "content-type: application/json" \
-d '{ "name": "Acme", "schema": "customer-brain" }'

Built-ins win a name collision, so a registration can never shadow nicia-base or nicia-core. A slug that is neither built-in nor registered for your organization is refused with a 400 validation when the Brain is created or revived — never a silent fall back to the default.

Registration is POST /v1/schemas, with a management key. That is the console’s schema surface and it takes the schema-editor proposal body, not the inline { "kinds": … } shape PUT /v1/brains/{handle} accepts — so if you want the vocabulary on one Brain only, declare it inline below instead. To start a Brain from the org baseline and add to it, extend the same slug: see Custom schemas.

This is also the one way to declare a field on Brains that already exist. Commit the same slug again with the field added — evolution is additive, so adding a kind or a field is accepted and removing a declared one that still holds records is refused — then POST /v1/schemas/{slug}/rollout. Brains still tracking the slug have it applied directly; Brains that have grown local kinds of their own are never written silently, they get the merged proposal as a review item. Values already stored under the new field become governed and are returned, with no re-import; non-slug predicates still return 400 index_not_ready.

A Brain is on the slug when its create call named it. A Brain that declared an inline schema is filed under a per-Brain vocabulary of its own, even when that schema extends this slug, so it is not in this fleet and a rollout does not reach it. Name the slug if you want the fleet; declare inline if you want one Brain’s own shape.

A pack is a schema we maintain for a common domain, so a team whose shape is already well known does not author one. Use the same pack across a fleet and one query works against every Brain in it.

The Standing Answers kinds (statement, standing_question, standing_answer) exist as a pack in the platform, but they are merged into a Brain’s existing schema rather than chosen at create time, so they are not a "schema" value either.

When you know your ontology, declare it inline on the create call:

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",
"fields": [
{ "name": "environment", "type": "enum", "values": ["dev", "staging", "prod"] },
{ "name": "released_at", "type": "string" },
{ "name": "rollback", "type": "boolean" }
] }
],
"links": [
{ "name": "deployed_by", "from": ["deployment"], "to": ["person"] }
]
}
}'

Field types are string, number, boolean, enum, and string_list. Keep it small: a handful of kinds with a handful of fields each extracts far more reliably than a sprawling ontology. Start generic and add as you learn.

See Custom schemas for the field types, the merge rules of extends, and what to do when your model is only half finished.

You do not have to guess. Write documents and untyped records for a while, then ask what shape your data actually has:

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": 412,
"kinds": [
{
"kind": "deployment",
"records": 412,
"fields": [{ "name": "environment", "present": 400, "coverage": 0.97 }]
}
]
},
"suggestions": [
{
"id": "deployment.health_score",
"kind": "deployment",
"field": { "name": "health_score", "type": "number" },
"occurrences": 264,
"coverage": 0.64,
"reason": "Recurring undeclared key observed on 264 deployment records."
}
],
"violations": [],
"limits": { "analyzedRecords": 412, "truncated": false, "minimumOccurrences": 2 }
}
}

Undeclared keys remain healthy preserved data and appear only as suggestions. violations[] contains only present declared values that cannot satisfy their declared type or enum. Promote what looks right, ignore the rest. This is the recommended path for anyone who is not certain of their model on day one — which is almost everyone.

Adding kinds, fields, links, and enum values is supported through a governed Review proposal. Removing or changing an existing declaration is a 409 — Nicia will not silently discard or reinterpret data you already wrote.

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

The proposal carries baseDeclaredVersion, an idempotencyKey, and the complete evolved schema returned by GET /schema. See Custom schemas for the exact flow.

Records written before a field existed keep the value they were sent with — it becomes part of the declared vocabulary and returns once Review accepts and the materialization verifies ready, because nothing was dropped on the way in. Non-slug predicates still return 400 index_not_ready until property indexes ship.