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.
Five tiers, and a continuous climb
Section titled “Five tiers, and a continuous climb”| 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
kindis a property you attach later. Turning an untyped record into a typedorganizationdoes 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.
When a schema is chosen
Section titled “When a schema is chosen”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.
Which tier am I?
Section titled “Which tier am I?”| 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 |
The default: nicia-base
Section titled “The default: nicia-base”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 (person → company), invested_in (person or company →
company or deal), founded (person → company), advises (person →
person 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.
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" } }'Untyped records
Section titled “Untyped records”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.
The neutral vocabulary: nicia-core
Section titled “The neutral vocabulary: nicia-core”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:
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 (event → person or
organization), works_at (person → organization). 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.
Your organization’s own schema
Section titled “Your organization’s own schema”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:
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.
Custom
Section titled “Custom”When you know your ontology, declare it inline on the create call:
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.
Letting the schema emerge
Section titled “Letting the schema emerge”You do not have to guess. Write documents and untyped records for a while, then ask what shape your data actually has:
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.
Changing your mind
Section titled “Changing your mind”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.
curl -X PATCH "$API/v1/brains/tenant:acme/schema" \ -H "authorization: Bearer $NICIA_KEY" \ -H "content-type: application/json" \ --data @complete-declared-schema-proposal.jsonThe 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.
- Records — writing against the vocabulary.
- Custom schemas — declaring and evolving your own.
- Search and queries — what typed fields buy you at read time.