This is the full developer documentation for Nicia Docs # Nicia Brains > Knowledge that improves as fast as your agents work. One Brain your people and agents both read from and improve, with a receipt for every answer. Knowledge that improves as fast as your agents work. A **Brain** holds what a customer, a project, a team, or an operation **knows**: the decisions, the standards, the current answer. Not what it bills or ships — that stays in your database, your CRM, your ledger. Documents, typed records, and the evidence behind every claim, in one place with one set of keys and one review queue. Your people and the agents you already run both read from it and both change it. Turn review on and nothing lands until someone approves — it is already on for a Brain you create in the app, and off for one your code provisions over the API, where a trusted pipeline writes directly. Either way, Nicia tells you where every sentence came from. Agents can rework it on a branch without moving what everyone else reads, and propose only the changes worth keeping back to the main Brain. Branches are in [preview](/docs/api/stability/). Agents don’t need memory. They need a Brain. One Brain · chain of custodyIllustrative lifecycle 01**Sources** **Records **Documents **Agent writes 02**Review** Proposed**Human or policy decision**Accept · edit · reject 03**Context** Accepted state**Cited answer**\[1] source passage 04**Receipt** Verified`rcp_019f`What the read consumed ```bash curl https://api.nicia.ai/v1/brains/tenant:acme/context \ -H "authorization: Bearer $NICIA_KEY" \ -H "content-type: application/json" \ -d '{ "prompt": "What should I know before this renewal call?" }' ``` ```json { "data": { "text": "Renewal closed 2026-03-14. Acme renewed at $120k for 24 months.", "citations": [ { "source": "hubspot:deal:412", "slug": "brain-evidence-0dd3e2449851d9ba41924c42bd7a61375a8b78050bfe2ca7192eb918d626236a", "quote": "Renewal closed 2026-03-14. Acme renewed at $120k for 24 months." } ], "staged": 0, "warnings": [], "receipt": "rcp_019ffefe", "truncated": false } } ``` Run one Brain for yourself or one per customer across a fleet of millions. It is the same handful of calls either way, and you address every one of them by **your own handle** — no Nicia ids to store. ## Two ways in [Section titled “Two ways in”](#two-ways-in) **[Build context into your product →](/docs/start/quickstart/)** You are shipping an AI product and every customer needs their own context. Create a Brain per customer, write documents or records, request context, and scale the fleet — without building retrieval infrastructure you did not want to own. *Three calls, about five minutes.* **[Share your team’s knowledge →](/docs/team/bring-it/)** Your team’s knowledge is a folder of markdown on somebody’s laptop, a wiki export, or a pile of pages in Notion and Drive. Bring it somewhere everyone — and everyone’s agents — reads the same copy, and connect Claude Code, Codex, or anything else that speaks MCP. *One command, about two minutes.* Both paths are the same Brain and the same API. Start wherever you are. ## Nothing to connect [Section titled “Nothing to connect”](#nothing-to-connect) Nicia is not a connector layer between your existing tools. Notion, Docs, Drive and Confluence are editing surfaces built for people, and they are good at that: their unit is a page, their API is designed around a person clicking, and nothing in them carries a typed field, a review step, or a record of why something changed. A document is a fine rendering and a bad database. So an import **reads, and never writes back**. Nothing in your existing tools is modified, no sync is established, and no credential of ours touches your systems. What you get is a copy, which makes this cheap to try and cheap to abandon. A copy does drift once people keep authoring somewhere else, so treat it as a trial with a shelf life rather than a resting state: run both for a week or two, compare the answers, and then decide whether the Brain becomes the place you write. That cutover is your call and your moment. Knowledge keeps being born in Slack, email, and meetings, and your agents can keep bringing it in. Capture is many to one. Authority is one place. ## Put knowledge in [Section titled “Put knowledge in”](#put-knowledge-in) Both on-ramps work on a brand-new Brain with no schema step. * **[Documents](/docs/write/documents/)** — text, markdown, transcripts, and whatever metadata you already have. The Brain takes text, not bytes, so a PDF is yours to convert first. Nicia chunks, indexes, and extracts. * **[Records](/docs/write/records/)** — typed structured data. Send what is now true and Nicia works out what changed. When a customer revises their content as a whole set, send it as a **[snapshot](/docs/write/snapshots/)** of a [source](/docs/write/sources/) (preview): the Brain shows what the new set would change — including what it would retire — before anything is served. ## Get context out [Section titled “Get context out”](#get-context-out) * **[Context](/docs/read/context/)** — one prompt in, grounded text and citations out. This is the endpoint most integrations only ever need. * **[Search and queries](/docs/read/search/)** — full-text and semantic search, and typed filters on every declared field: equality, sets, and ranges over numbers, dates, and instants. ## Shape it [Section titled “Shape it”](#shape-it) * **[Schemas](/docs/schemas/overview/)** — start with none, keep the default, pick the neutral vocabulary, reuse a schema your organization has already registered, or declare your own. Chosen when the Brain is created. ## Trust it [Section titled “Trust it”](#trust-it) * **[Review](/docs/govern/review/)** — agents propose, you accept. Trusted pipelines write directly. * **[Receipts](/docs/govern/receipts/)** — every read records exactly what it was shown, so you can defend an answer months later. * **[Events and webhooks](/docs/govern/events/)** (preview) — one ordered stream of what the Brain accepted and how its changes moved, read by cursor or pushed to your URL. ## Run it at scale [Section titled “Run it at scale”](#run-it-at-scale) * **[One Brain per customer](/docs/scale/fleet/)** — provisioning as a pure function of your tenant id. * **[Handing out keys](/docs/scale/brain-keys/)** — scoped keys for an app, a department, or an end user. ## Work as a team [Section titled “Work as a team”](#work-as-a-team) * **[Bring your knowledge](/docs/team/bring-it/)** — a folder, a Drive, or a repo, imported and searchable in one command. * **[Share it](/docs/team/share-it/)** — invite people, connect Claude Code and Codex to the same copy. * **[Keep it good](/docs/team/tend-it/)** — review, agent-assisted maintenance, and export whenever you want. # 404 > That page doesn't exist. # Errors > The error envelope, error kinds, and HTTP status codes. Errors are returned as a JSON envelope with a machine-readable `kind` and a human-readable `message`: ```json { "error": { "kind": "not_found", "message": "Run not found" } } ``` Branch on `kind` (stable); show `message` to humans (may change). ## Error kinds [Section titled “Error kinds”](#error-kinds) | `kind` | HTTP | When | | -------------- | ---- | --------------------------------------------------------- | | `validation` | 400 | The request body, params, or query failed validation. | | `unauthorized` | 401 | Missing or invalid API key. | | `forbidden` | 403 | Authenticated, but not allowed (e.g. wrong organization). | | `not_found` | 404 | The resource doesn’t exist (or isn’t in your tenant). | | `conflict` | 409 | The request conflicts with current state. | | `rate_limited` | 429 | Too many requests — back off and retry. | | `internal` | 500 | An unexpected server error. | | `unavailable` | 503 | A dependency was temporarily unreachable — retry. | `rate_limited` and `unavailable` are the two **transient** kinds; every other kind is a decision about your request and will answer the same way again. ## An input we do not understand is refused, never ignored [Section titled “An input we do not understand is refused, never ignored”](#an-input-we-do-not-understand-is-refused-never-ignored) A query parameter an endpoint does not declare is always a `400` naming it — enforced once, for every endpoint. A body field gets the same refusal on the endpoints whose body is strict (`POST …/keys`, `POST …/context`, `POST …/query`); elsewhere an undeclared body field is dropped rather than refused, so validate what you send against the contract rather than relying on the server to catch a typo: ```json { "error": { "kind": "validation", "message": "filter: unknown query parameter for GET /v1/brains/:handle/search. It accepts: q, mode, limit, consumerId." } } ``` This matters most where a parameter would have NARROWED an answer. Dropping an unrecognized `filter=` and answering `200` returns a confident, well-formed ranking over the whole Brain to a question that asked for one slice of it — and nothing in the response says so. Refusing it is the only answer you can act on, and it is why a client generated against an older shape of a preview endpoint fails loudly at the parameter it should stop sending rather than quietly reading something else. ## Correlating a request [Section titled “Correlating a request”](#correlating-a-request) Every `/v1/*` response — success or error — carries an `x-request-id` header. Log it alongside the request you made; when you report a failed call, include it so the failure can be found on our side without you having to attach a full HTTP transcript. It has no fixed format: usually a UUID, but on some requests it is Cloudflare’s own `cf-ray` value, echoed as-is rather than minting a second id for the same request. ```bash curl -sD - https://api.nicia.ai/v1/brains \ -H "authorization: Bearer $NICIA_KEY" \ | grep -i x-request-id ``` ## Retrying [Section titled “Retrying”](#retrying) `429` and `5xx` are safe to retry with exponential backoff. When retrying a write that is not addressed by an id, send an [`Idempotency-Key`](/docs/api/idempotency/) so a retry never repeats it. `4xx` errors other than `429` indicate a problem with the request itself — fix it rather than retrying. # Idempotency and retries > Which writes are naturally safe to repeat, and which need an Idempotency-Key. Network calls fail, and a retry must not create a second of anything. Nicia splits writes into two groups, and you only have to think about one of them. ## Addressed writes are naturally idempotent [Section titled “Addressed writes are naturally idempotent”](#addressed-writes-are-naturally-idempotent) When you name the thing you are writing, the address is the identity. Sending the same request twice is one write, and there is nothing to configure. | Operation | Why it is safe | | -------------------------------------------------------------- | ------------------------------------------------------------------------- | | `PUT /v1/brains/{handle}` | Your handle is the identity | | `PATCH` / `PUT` `…/records/{recordId}` on a record that EXISTS | The id is the identity | | `POST …/documents` **with** an `id` | An upsert on your document id | | `POST …/documents` **without** an `id` | Content-addressed — Nicia derives `sha256:` from the text as the id | This is why the [documents](/docs/write/documents/) page recommends sending your own `id` and the [records](/docs/write/records/) page recommends deriving slugs from your own system: it turns a nightly sync into a plain loop with no bookkeeping. A document with no `id` gets the same idempotency for free, by a different route: the id is derived from the text itself, so sending the same content twice is one document, not two. That trade cuts the other way from everything else on this page — two documents you meant to be separate collapse into one if their text happens to be byte-identical. Send your own `id` when you mean two. The one gap the address cannot close is a **create** whose answer you never received. A retry re-reads the address, finds nothing there — because the first attempt may have failed, or may have staged for review — and writes again. Nicia will not paper over that by hashing your request into a key: two creates you meant separately are byte-identical to a retry, so a derived key would answer the first one’s outcome to the second and drop a write while reporting success. Send a key on a create instead; every record write verb accepts one. ## Non-addressed writes take an `Idempotency-Key` [Section titled “Non-addressed writes take an Idempotency-Key”](#non-addressed-writes-take-an-idempotency-key) When the server assigns the identity, a retry cannot be told apart from a deliberate repeat — so you say which it is. ```bash curl -X POST "https://api.nicia.ai/v1/brains/tenant:acme/batch" \ -H "authorization: Bearer $NICIA_KEY" \ -H "content-type: application/json" \ -H "idempotency-key: sync:acme:2026-08-14" \ -d '{ "records": [ … ] }' ``` | Operation | Without a key | | -------------- | ----------------------------------------- | | `POST …/batch` | At-least-once | | `POST …/keys` | At-least-once — may mint two live secrets | Rules: * The **first** request with a given key does the work. * A **retry** with the same key and same body returns the same result, including the original ids. That is how you tell a replay from fresh work: the `change` you get back carries the first request’s id, not a new one. * The same key with a **different body** is a `409 conflict`. Use a fresh key for a different operation. * A key is scoped to one Brain and the key that sent it, and it is kept for as long as the Brain is. There is no expiry window to race. Nicia deliberately does **not** derive a key from a hash of your request body. That would make two intentionally identical operations — two statements of the same measurement, two keys with the same name, a batch you meant to send twice — permanently indistinguishable. `POST …/keys` follows the same header, with one difference in what a replay returns: the key record with a `null` secret rather than the original result — the secret is shown exactly once, at the first mint, and Nicia cannot show it again. Every write that is not addressed takes the header — there is no `idempotencyKey` body field anywhere in the API. ## Choosing a key [Section titled “Choosing a key”](#choosing-a-key) Use something stable for the logical operation, not something random per attempt: `sync:acme:2026-08-14`, `app-key:acme`, an order id, or a UUID you generate **before** the first attempt and reuse across retries. A genuine second operation deserves a genuine second key. If a nightly job runs twice on purpose, fold the run into the key. ## What to retry [Section titled “What to retry”](#what-to-retry) Retry `429` and `5xx` with exponential backoff, honouring `Retry-After` when it is present. Do not retry other `4xx` — they describe a problem with the request that will answer the same way again. See [Errors](/docs/api/errors/). The rule for repeating a write is the same either way: an addressed write is always safe; a non-addressed write is safe when it carries a key. # Stable and preview operations > Which operations you can build on, and which ones may still change or be removed without notice. Every operation is either **stable** or **preview**. A stable operation is the launch surface: build on it. A preview operation ships and works, but it is not a commitment. It may change shape or be removed without notice. ## Where you see it [Section titled “Where you see it”](#where-you-see-it) The marker is one field on the operation, and everything you read derives from it, so nothing disagrees: | Where | What it shows | | --------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | [API reference](/docs/reference/) | Every preview operation is also listed under the **Preview (unstable)** tag, with `[Preview]` before its summary and a one-sentence warning first in its description. | | OpenAPI document | `x-nicia-stability: preview` on the operation. Stable operations carry no such field. | | TypeScript client | `stability` on the operation in the generated manifest, and a `@preview` line in the hover documentation of the method and its types. | | These docs | A **Preview** aside on every section that documents a preview operation. | ## What is preview today [Section titled “What is preview today”](#what-is-preview-today) Preview — this list may change without notice Operations move onto and off this list as they are proven, and each move is written in the [changelog](/docs/changelog/). * **Durable branches and Proposals.** Creating, inspecting, writing, comparing, archiving, restoring or destroying a branch; opening, listing, inspecting, revising or closing a Proposal. * **Governed definitions.** Inspecting, proposing and reviewing inferred definitions. * **Part of the change lifecycle.** Selecting a subset of a change, scheduling and cancelling one, and reading its journal. * **Sources and snapshots.** Listing and reading sources, and opening, adding to, sealing, reading, applying and cancelling a snapshot. * **The text a receipt pinned.** Reading the document text behind a receipt. * **Queryable receipts.** Listing receipts, and who was served a record, a document, or a source’s document. * **Events and webhooks.** Reading the event stream, and creating, listing and deleting the webhooks that push it. Everything else is stable: documents, records, statements, context, query and search, changes with approve, reject and publish, reading a receipt and what changed since it, the schema, and keys. # Build with Nicia > A concise bootstrap procedure for coding agents integrating a Brain. ## Outcome [Section titled “Outcome”](#outcome) You will give each tenant a stable Brain, sync the structured records your product already owns, add source documents, and read grounded context with citations. Nicia does not replace your system of record. ## Read in this order [Section titled “Read in this order”](#read-in-this-order) 1. [Overview](/docs/) 2. [Quickstart](/docs/start/quickstart/) 3. [Add a Brain](/docs/start/add-a-brain/) 4. [Authentication](/docs/start/authentication/) 5. [Errors](/docs/api/errors/) and [Idempotency](/docs/api/idempotency/) 6. [OpenAPI reference](/docs/reference/) ## Bootstrap sequence [Section titled “Bootstrap sequence”](#bootstrap-sequence) 1. Set `API=https://api.nicia.ai` and keep credentials server-side. 2. Choose an immutable handle such as `tenant:${stableTenantId}`. Do not use a mutable company slug. 3. Provision with `PUT /v1/brains/{handle}` and set `mode` to `reviewed`. 4. Use your management credential only in your provisioning backend. Mint a sync key with `write` and `review`, and an agent key with `read` and `write` but never `review`. 5. Write application-owned records with deterministic ids and stable idempotency keys. Approve only the change id returned by that sync write. 6. Add non-empty source text as addressed documents. A batch or document write must carry at least one item — an empty collection is a `400` validation error, so skip the call when nothing changed. A write whose values already match what is stored succeeds as a no-op instead, coming back with `fields: []` and no change id. An empty document or malformed request fails validation, and missing rows never mean deletion. 7. Run a typed query and a `/context` read. Surface its citations and receipt to your users. 8. Submit an agent-authored update. It must stage for review while accepted state remains unchanged. 9. Retry only `429` and `5xx` with the same idempotency key or address, honor `Retry-After`, then revoke credentials and clean up your test Brain. ## Success checks [Section titled “Success checks”](#success-checks) * Repeating provisioning returns the same Brain. * Repeating a write does not create a duplicate record or change. * Context includes a citation for the document you added. * An agent key cannot settle its own staged change. * An empty record or source collection is a `400`, not a no-op — skip the call when nothing changed. A write whose values already match what is stored succeeds as a no-op instead, with `fields: []` and no change id. Empty documents and malformed input fail visibly without partial state. See the [detailed activation guide](/docs/start/add-a-brain/), [API errors](/docs/api/errors/), [idempotency guidance](/docs/api/idempotency/), and [OpenAPI JSON](/docs/openapi.json). # Changelog > Every change to the Brain API surface, dated, including the ones made while it is still in beta. The Brain API is in beta. That label buys exactly one thing — the freedom to change a shape before anyone is depending on it — and it costs one thing in return: every change gets written down here, including the ones that remove something these pages used to describe. ## 2026-09-30 — Cloning a Brain can copy its documents [Section titled “2026-09-30 — Cloning a Brain can copy its documents”](#2026-09-30--cloning-a-brain-can-copy-its-documents) **Changed:** a console clone now offers two choices. `empty` is structure only: the declared schema and governance, with no records and no documents. `full` is everything: the schema, every record, and every document the source Brain serves. Copied documents are new submissions in the clone (fresh identities, the same bytes and metadata, served at once), and a record’s cited documents are re-pointed at the clone’s copies. Only the served version of a document is copied, so a citation of a superseded version is dropped rather than left pointing at the source Brain. Previously a full clone left documents behind. ## 2026-09-30 — A cited document that moved is never served as current [Section titled “2026-09-30 — A cited document that moved is never served as current”](#2026-09-30--a-cited-document-that-moved-is-never-served-as-current) **Added:** `support` on a record a read serves, on `GET /records/{id}`, `POST /query`, `/context`, `/search`, a view read and the MCP tools, when a document the record cites has moved or been withdrawn since the citation pinned it. `state` is `source_withdrawn` or `support_at_risk` (the cited words are no longer in the served version), and `reasons` names each document. A record whose citations still hold carries none. **Changed:** `/context` serves a statement whose cited document moved or was withdrawn without its text, as it serves a standing answer that is not `current`. This closes the gap where a withheld answer’s citations were served beside it with their old quotes, so an agent read “Free tier | 10 Brains” after the row said 3. Any other record is served whole and marked. A `/search` hit on such a statement has an empty `excerpt`. **Added:** a warning of kind `evidence_moved` on `/context`, and `evidence[]` on `GET /receipts/{receiptId}/now`: each record the receipt served whose cited documents have since moved or been withdrawn, with the `support` it was served with and its `support` now, even when the record’s own version is unchanged. ## 2026-09-30 — Your own agent can keep a monitored answer [Section titled “2026-09-30 — Your own agent can keep a monitored answer”](#2026-09-30--your-own-agent-can-keep-a-monitored-answer) **Added:** `anchor` on an `evidence` citation of a record, batch or statement write: `§ ` or `L12-L14`. The Brain resolves it over the document version it serves, refuses one that names nothing, is ambiguous, or does not contain your `quote`, and computes the evidence pins itself. A `statement` that cites an anchor is monitored, so a standing answer that `depends_on` it reads `current`, and `needs_review` when that part of the document changes. Reads show the resolved anchor on the citation. `sourceAnchor` and `anchorHash` join `startByte` and `endByte` as locator keys only the Brain writes. A statement cites one anchored part. **Added (preview):** the MCP tool `brain_documents`: list the documents a Brain serves with each one’s outline, or read one document or one section of it. Reading text is receipted. **Added:** `staged` and `warnings[]` on MCP `brain_context`, the same ones `POST /context` reports: a source that did not fit, a change waiting in review, an answer that is not current. **Changed:** a `statement` that cites exactly one document and states its own `sourceAnchor` rests on that anchor, as if it had been sent as the citation’s `anchor`; a `sourceAnchor` the document does not support is now refused. A record write’s `body` field is prose on every kind and is no longer reported as `undeclared`. ## 2026-09-30 — A spreadsheet becomes records through a snapshot [Section titled “2026-09-30 — A spreadsheet becomes records through a snapshot”](#2026-09-30--a-spreadsheet-becomes-records-through-a-snapshot) **Added (preview):** `POST /v1/brains/{handle}/sources/{name}/snapshots/{snapshotId}/tables` adds a CSV or TSV table to a snapshot, one document per row, with a mapping from columns to a declared kind’s fields. The row’s identity column, made into a slug, is the record’s id. Sealing reads every mapped cell into its field without a model, so the snapshot is `planned` when the seal returns; a cell its field cannot hold is kept as a statement. A table with an empty, unaddressable, or repeated identity is refused whole with `400`, naming the rows. A `complete` refresh retires the rows a later export no longer holds; a `partial` one withdraws nothing. See [Add a table](/docs/write/snapshots/#add-a-table). **Changed:** an apply that writes more than 400 records commits them in batches ahead of the write that serves their documents, so a 2,000-row table loads as one `complete` snapshot in one call. A retry after an interruption does not rewrite what already committed. This brings the registered v2 Brain surface to 84 operations. Preview — source snapshots may change without notice Adding a table to a snapshot is preview, like every source and snapshot operation: it works, but its shape may change or be removed. ## 2026-09-28 — Search finds standing answers again, with their state [Section titled “2026-09-28 — Search finds standing answers again, with their state”](#2026-09-28--search-finds-standing-answers-again-with-their-state) **Added:** `kind` on a `/search` hit for a typed record, and `validity` and `verification` on a standing answer’s hit. An answer whose grounds moved, or cannot be checked, is shown by its question alone. Its page is still never returned as an untyped passage. A search receipt now records what it served, so `GET /receipts/{id}/now` compares a searched answer’s grounds too. ## 2026-09-28 — A receipt says when an answer it served lost its grounds [Section titled “2026-09-28 — A receipt says when an answer it served lost its grounds”](#2026-09-28--a-receipt-says-when-an-answer-it-served-lost-its-grounds) **Added:** `answers[]` on `GET /receipts/{receiptId}/now`. For every standing answer the receipt served, it gives the validity it was served with and its validity now, and `since`: `grounds_moved` (even when the version is unchanged), `replaced`, `unchanged`, or `unknown` (for a receipt that predates validity). `movedSince` names each reason that arose after the read. It reports what changed after the read, never that the answer was wrong when served. **Fixed:** a retired source document now puts the answers that rest on its claims in `needs_review` (`source_withdrawn`). Previously a retired document could still read as supporting them. ## 2026-09-28 — A standing answer says whether it was checked [Section titled “2026-09-28 — A standing answer says whether it was checked”](#2026-09-28--a-standing-answer-says-whether-it-was-checked) **Added:** `verification` beside `validity` on every standing answer a read serves: `verified` (the served version passed its regression check for that exact revision, with no override), `overridden` (published on a reviewer’s recorded reason, with the check’s outcome kept), `not_evaluated` (written directly, without Review), or `unknown`. A direct update never inherits an earlier version’s verdict. Exports carry it in `answers.json`. **Changed:** approving a change that carries a standing answer now requires a passing, current regression check, or an `evaluationOverrideReason` on `POST /changes/{changeId}/lifecycle/approve`. A check still running answers `409`. The gate governs Review approval only; whether an agent’s write enters Review is still the Brain’s admission level. ## 2026-09-28 — A standing answer says when its grounds moved [Section titled “2026-09-28 — A standing answer says when its grounds moved”](#2026-09-28--a-standing-answer-says-when-its-grounds-moved) **Added:** `validity` on every standing answer a read serves: `/context`, `/query`, `GET /records/{id}`, a view read, and the MCP `brain_context` and `brain_query` tools. It is `current`, `needs_review` (with `reasons` naming each claim that changed or was removed, each claim whose quoted source text changed, and each new claim from a document the answer already uses), or `unknown` (when Nicia cannot tell, including an answer that rests on no claim). Grounds are measured from `affirmed`, the answer’s creation or the last time a person or a reviewer decided it still holds, so an agent’s unreviewed rewrite cannot clear them. It also names the `monitored` claims and documents, and the log position the check read through (`observedThrough`). Once the grounds move, `rebuild` reports what the rebuild automation did, including a failed rebuild. **Changed:** `/context` withholds the text of a standing answer that is not `current`. It serves the question and `validity` only, and adds a warning of kind `answer_not_current`. **Changed:** `/search` no longer returns a standing answer’s text, from either its record or its page, because a search hit carries no validity. **Added:** a Brain export carries `answers.json`, each standing answer’s validity as of the export. ## 2026-09-27 — Every value knows who said it [Section titled “2026-09-27 — Every value knows who said it”](#2026-09-27--every-value-knows-who-said-it) **Added:** `GET /v1/brains/{handle}/records/{recordId}/fields/{field}` returns a field’s look-behind. It gives the value held, the producers whose latest word is that value (`support`), the other values producers still assert (`dissent`), and every assertion and retraction in recorded order (`history`). `asOf` answers as the Brain had recorded the field at an instant. Every accepted write now records per-field attribution: a person, an agent, an API key, or a source. Records accepted before this release are attributed to `internal:system:legacy`. **Added:** views. A schema may declare named, read-only views over its records, each with `bindings` (the declared field an everyday term means), `prefer` (whose word wins when producers disagree), and optional `fields` and `kinds`. `GET /v1/brains/{handle}/views/{view}/records/{recordId}` reads a record through one, receipted with the record version and the view’s schema version. `?view=` on a field’s look-behind answers through a view. A `write` key may add a view; changing or removing one needs `admin`. The registered v2 Brain surface now has 83 operations. **Changed:** an agent’s write that would change a value **a person** set now waits for review below L4, instead of overwriting it. Agents keep updating values that other agents or keys set. ## 2026-09-27 — A person’s snapshot no longer overwrites a teammate’s value [Section titled “2026-09-27 — A person’s snapshot no longer overwrites a teammate’s value”](#2026-09-27--a-persons-snapshot-no-longer-overwrites-a-teammates-value) **Fixed:** a source snapshot applied with a management key or a console session counts as a person’s write, and a person’s writes land at every level. So when such a snapshot contradicted a value someone had set by hand, its replacement value landed, and the hand-set value was overwritten. That broke the rule that a snapshot never overwrites a value a person or another source supports. The change now waits in Review at every level below L4, exactly as a Brain key’s does. Existing Brains pick up the corrected rule when their seeded policy is re-materialized. ## 2026-09-27 — Limits you could only find by hitting them [Section titled “2026-09-27 — Limits you could only find by hitting them”](#2026-09-27--limits-you-could-only-find-by-hitting-them) **Documented:** a source snapshot extracts at most 200 typed values and 100 statements from one document. A document over either cap fails as a whole: its plan entries are `unknown`, nothing is truncated. The snapshot limits table now lists both. **Changed:** the `brain_write_records` tool description states its 20-record cap and tells an agent to send a larger set, such as spreadsheet rows, as several calls with their own `idempotencyKey`. It used to say only “one atomic batch”, so an agent with a long list learned the cap from a validation error. **Changed:** the Brain Agent and the `build.md` and `import.md` procedures no longer turn away operational data. Leads, accounts, invoices, and activity logs can live in a Brain; whether they should is your call. ## 2026-09-25 — Brain MCP tools are named `brain_*` [Section titled “2026-09-25 — Brain MCP tools are named brain\_\*”](#2026-09-25--brain-mcp-tools-are-named-brain_) **Breaking:** every tool on the Brain MCP server is renamed from `knowledge_*` to `brain_*`, with no aliases: `brain_schema`, `brain_query`, `brain_context`, `brain_events`, `brain_write_document`, `brain_write_records`, `brain_evolve_schema`, `brain_receipts`, `brain_reads`, and `brain_proposals`. Inputs, outputs, and capabilities are unchanged. A client that cached the old tool list must list tools again; an agent prompt or skill that names a `knowledge_*` tool must use the new name. ## 2026-09-25 — Agents can propose the schema they need [Section titled “2026-09-25 — Agents can propose the schema they need”](#2026-09-25--agents-can-propose-the-schema-they-need) **Changed:** `PATCH /v1/brains/{handle}/schema` and `POST /schema/promote` accept a Brain key with `write` as well as `admin`. Submitting a schema change is not deciding it: the Brain’s mode decides, as it does for record writes, so an agent’s additive change lands on an `open` Brain and waits in review on a `reviewed` one. Removing or retyping declared structure is still refused. A key without `admin` may only add new kinds, fields, and links; changing an existing declaration, including an enum’s values or a safeguard, is a `403` for it (`SCHEMA_EDIT_NEEDS_ADMIN` over MCP). **Added:** the `brain_evolve_schema` MCP tool, offered to keys that can read and write. A `brain_write_records` refusal for an undeclared `kind` now lists `undeclaredKinds` in its `action` and points to `brain_evolve_schema`, or tells a write-only key to ask its operator. **Changed:** schema candidates suggest `date`, `datetime`, and — for a small set of recurring labels — `enum` fields, not only `string`, `number`, `boolean`, and `string_list`. Dates mixed with free text are suggested as a `string` instead of being dropped. ## 2026-09-25 — Query records across the Brains on a schema [Section titled “2026-09-25 — Query records across the Brains on a schema”](#2026-09-25--query-records-across-the-brains-on-a-schema) **Added:** `POST /v1/schemas/{slug}/query` runs one typed record query across every Brain built on one of your organization’s schemas, with the `kind` and `where` of `POST /v1/brains/{handle}/query` checked against the organization schema. Records carry their Brain’s handle; each Brain reports an `outcome` and, when served, the receipt its read committed. See [querying across the fleet](/docs/scale/fleet/#querying-across-the-fleet). ## 2026-09-25 — Published changes leave the review queue [Section titled “2026-09-25 — Published changes leave the review queue”](#2026-09-25--published-changes-leave-the-review-queue) **Fixed:** a staged change published through `POST /changes/{changeId}/lifecycle/publish` is no longer counted as awaiting review. `/context`’s `staged` kept counting it, and the console’s Review queue kept listing it with its rows pending. ## 2026-09-25 — Snapshot changes in the review queue; withdrawn statements [Section titled “2026-09-25 — Snapshot changes in the review queue; withdrawn statements”](#2026-09-25--snapshot-changes-in-the-review-queue-withdrawn-statements) **Fixed:** `GET /changes?status=staged` no longer fails with `500` once a source snapshot’s change is waiting for review. A change now lists the source documents it serves or retires under a new optional `documents` field (`{ source, id, action }`, `action` being `serve` or `retire`); `records` holds records only. **Fixed (preview):** when a snapshot withdraws a document — a `complete` snapshot leaves it out, or its new version no longer says it — the statements only that document made are retracted with it, in the same outcome: a retired document’s statements wait in its review. A statement a document the source still serves makes is kept. Before, they stayed `active` and `/context` kept serving them. ## 2026-09-23 — Three-way compare for branches [Section titled “2026-09-23 — Three-way compare for branches”](#2026-09-23--three-way-compare-for-branches) **Changed (preview):** `GET /branches/{branchId}/compare` and branch Proposals are three-way. They report the branch’s changes since its fork, applied onto main as main is now. Compare no longer fails with `409` once main has moved past the fork. Changes main made after the fork are no longer reported as branch changes. Edits to different fields of the same record merge. A field, record, or document that both sides changed differently is reported as a conflict, with main’s value at the fork, main’s value now, and the branch’s value. `diff` is now typed as `records` and `documents`, and `summary` gains `conflicts`. A Proposal with conflicts still opens. Its conflicting operations wait in review, and only an explicit approval publishes the branch’s value. **Fixed:** listing a branch’s documents no longer stops paging early when a page contains a document pin without a stored version. Preview — branches and Proposals may change without notice These operations work, but their shapes may change or be removed. ## 2026-09-23 — Read-only branches; reads never wait on a writer [Section titled “2026-09-23 — Read-only branches; reads never wait on a writer”](#2026-09-23--read-only-branches-reads-never-wait-on-a-writer) **Added (preview):** `POST /branches` accepts `mode: "read_only"`, a branch pinned at its fork point. It refuses every write with `409` — records, documents, and opening a Proposal — and a key scoped to it can hold only `read`. Branches report their `mode`. Omitting the body still creates a writable branch. **Changed:** reads of a branch no longer take its write lease, so concurrent reads, and a read beside a write, no longer fail with `409`. Each read is served from one committed branch head. **Changed:** destroying a branch now removes everything the fork and its writes created — indexes, date crossings, document pins, unreferenced staged documents, watermarks — and closes its open Proposals, so a destroyed branch no longer emits `date.reached`. Archiving a branch parks its date crossings and index rebuilds until it is restored. Preview — branches may change without notice These operations work, but their shapes may change or be removed. ## 2026-09-23 — Read a branch as its own Brain view [Section titled “2026-09-23 — Read a branch as its own Brain view”](#2026-09-23--read-a-branch-as-its-own-brain-view) **Added (preview):** `/context`, `/search`, `/query`, and document reads accept a `branch` coordinate. A branch-scoped key uses its branch implicitly. Branch records and staged document versions are indexed and served there without appearing on main. `/branches/{branchId}/compare` and Proposals include the document delta; approved ChangeSets publish it through main’s existing policy. **Changed:** read receipts pin `branchId`; receipt and who-was-served lookups respect it. Events carry `branchId`, and a branch-scoped key sees only its branch’s events. Branch answers include `access_scoped`. Branch documents currently require `extract: false`; direct branch extraction is not yet available. The v2 operation count remains 81. ## 2026-09-23 — Branch Proposals and scoped Brain keys [Section titled “2026-09-23 — Branch Proposals and scoped Brain keys”](#2026-09-23--branch-proposals-and-scoped-brain-keys) **Added (preview):** `GET /branches/{branchId}/compare` reads a branch’s logical delta against main without staging a change. `POST /proposals` pins a branch’s current delta for review; `GET /proposals`, `GET /proposals/{id}`, `POST /proposals/{id}/revise`, and `POST /proposals/{id}/close` manage its revisions. Snapshot review changes and staged direct agent writes also appear as Proposals. Applying a Proposal uses the existing ChangeSet lifecycle and main’s policy. Proposal events join the Brain event stream and webhook payloads. The MCP endpoint can list and inspect Proposals. **Added (preview):** `POST /keys` accepts an optional `branch` to mint a key restricted to one branch. Such a key can write that branch and open its Proposal, but cannot address main’s records or mint keys. **Removed:** the former merge planning endpoint and its automatic publication behavior. Branches are no longer called experiments on the wire. The registered v2 Brain surface now has 81 operations. Preview — branches and Proposals may change without notice These operations work, but their shapes may change or be removed. ## 2026-09-23 — Typed field queries, date fields, and `date.reached` [Section titled “2026-09-23 — Typed field queries, date fields, and date.reached”](#2026-09-23--typed-field-queries-date-fields-and-datereached) **Added:** `date` and `datetime` field types. A `date` is a calendar day (`YYYY-MM-DD`); a `datetime` is an ISO 8601 instant with its offset, stored and returned in UTC. Every write path holds a value to its declared type — record writes and batches (an update to an existing record included), statements whose predicate names the field, and source snapshot extraction. See [Dates and instants](/docs/schemas/overview/#dates-and-instants). **Changed:** `POST /v1/brains/{handle}/query` filters on every declared field. `where` takes a bare value (equality) or an operator object: `eq`, `ne`, and `in` on scalar fields, `has` on a `string_list`, and `lt`, `lte`, `gt`, `gte` on `number`, `date`, and `datetime` — at most eight predicates, AND-combined, each answered from a typed index. Paging and order are unchanged. See [Search and queries](/docs/read/search/#query--rows). **Changed:** a `where` predicate on an **undeclared** field is now a `400` saying the field is not declared. It used to answer `400 index_not_ready`, which now means only that a declared field’s index is being rebuilt after its declaration changed — retry shortly. A declared non-slug field no longer returns `index_not_ready` at all. The Brain MCP server’s `knowledge_query` takes the same predicates. **Added (preview):** a `date` or `datetime` field may declare `notifyBefore` ISO 8601 offsets (`P30D`, `P0D` for the date itself). When a record’s value minus an offset is reached, the event stream carries one `date.reached` — exactly once per record, field, value, and offset — and Brain-key webhooks deliver it like any other event. See [Date reached](/docs/govern/events/#date-reached). The registered v2 Brain surface has 81 operations after the Proposal additions. ## 2026-09-23 — Each organization’s schema registry is its own [Section titled “2026-09-23 — Each organization’s schema registry is its own”](#2026-09-23--each-organizations-schema-registry-is-its-own) **Fixed:** `POST /v1/schemas` checked a registered kind against every organization’s registrations: registering `product` refused with `409 conflict` when any other organization had registered a `product` with different fields. A registration is now the organization’s own — two organizations may register the same slug and the same kind names with different shapes, and each organization’s Brains, provisioned by slug or by `extends`, carry that organization’s shape. **Fixed:** `GET /v1/schemas` listed schemas other organizations had registered, and schemas the platform bundles for its own demos. It lists only your organization’s registrations, ordered by slug; `GET /v1/schemas/{slug}` answers `404` for a slug only another organization registered. **Changed:** `contract` is always present on `GET /v1/schemas/{slug}` and the commit response; `schema` is derived from it, as a Brain provisioned from the slug commits it. A schema-editor proposal is merged onto the registered contract and refused `409` only for dropping or retyping a declared kind or field. An organization holds at most 100 registered schemas; registering a new slug past that is `409 conflict`. ## 2026-09-23 — Preview: the event stream and Brain-key webhooks [Section titled “2026-09-23 — Preview: the event stream and Brain-key webhooks”](#2026-09-23--preview-the-event-stream-and-brain-key-webhooks) **Added (preview):** `GET /v1/brains/{handle}/events` (`read`) is one ordered, gap-free stream of what the Brain accepted and how its changes moved: `record.accepted` and `record.deleted` (with the names of the fields a change touched, never their values), `statement.asserted` and `statement.retracted`, `document.served` and `document.retired`, `schema.evolved`, `change.staged`, `change.decided` and `change.published`, `availability.changed`, and `snapshot.planned` and `snapshot.applied`. Events carry ids, field names and version numbers only. Page it with an opaque `cursor` passed back as `after`; filter it with `types`. **Added (preview):** `POST /v1/brains/{handle}/webhooks`, `GET /v1/brains/{handle}/webhooks` and `DELETE /v1/brains/{handle}/webhooks/{webhookId}` (`admin`; creating one also needs `read`) push chosen event types to an HTTPS URL on a public host, in order and at least once, signed with a secret shown once. Each body is one event as `GET /events` lists it plus the `previousCursor`, so a receiver that missed a delivery reads the gap from the stream. The Brain MCP server gains `knowledge_events`; `@nicia/brain-client` gains `listEvents`, `createWebhook`, `listWebhooks` and `deleteWebhook`. See [Events and webhooks](/docs/govern/events/). This brings the registered v2 Brain surface to 76 operations. Preview — events and webhooks may change without notice The event and webhook operations are preview: they work, but their shape may change or be removed. ## 2026-09-23 — Preview: queryable receipts [Section titled “2026-09-23 — Preview: queryable receipts”](#2026-09-23--preview-queryable-receipts) **Added (preview):** `GET /v1/brains/{handle}/receipts` lists receipts newest first, without their items, filtered by `consumerId`, `kind`, `key`, and a committed-time window (`since` inclusive, `until` exclusive). A key lists the receipts it committed; a key with `admin` lists every key’s. See [Listing receipts](/docs/govern/receipts/#listing-receipts). **Added (preview):** `GET /v1/brains/{handle}/records/{recordId}/reads`, `GET /v1/brains/{handle}/documents/{documentId}/reads`, and `GET /v1/brains/{handle}/sources/{name}/documents/{documentId}/reads` list every receipted read that served the address, from any key, with the version it served, the version served now, and `status` (`current`, `superseded`, `retracted`, `deleted`, `held`). `stale=true` keeps only the reads a correction made stale. They need `review` and return no content. See [Who was served](/docs/govern/receipts/#who-was-served). **Changed:** a key with `admin` — the management key included — can read any receipt on its Brain through `GET /receipts/{id}`, `/now`, and `/documents`, where before only the key that committed it could. A receipt still names the key that committed it. Any other key still gets `404`. **Changed:** the API export carries `receipts.json`: the receipts the exporting key can list, with their snapshots, hashes and items, never the text a read was handed. These pages said the export omits receipts. **Added (preview):** the Brain MCP server gains `knowledge_receipts` (list receipts, or get one) for a `read` key and `knowledge_reads` (who was served a record or document) for a `review` key. `@nicia/brain-client` gains `listReceipts`, `listRecordReads`, `listDocumentReads`, and `listSourceDocumentReads`. This brings the registered v2 Brain surface to 72 operations. Preview — queryable receipts may change without notice Listing receipts, the who-was-served reads, and the two MCP tools are preview: they work, but their shape may change or be removed. ## 2026-09-23 — Review declarations travel with the schema; statements on every Brain [Section titled “2026-09-23 — Review declarations travel with the schema; statements on every Brain”](#2026-09-23--review-declarations-travel-with-the-schema-statements-on-every-brain) **Changed:** `POST /v1/schemas` takes `{ "contract": … }`, a complete `DeclaredSchemaContract`, and registers it exactly as sent — each field’s `multiplicity` and `consequence` included — so a Brain provisioned from the slug, or `extends` it, carries them. `GET /v1/schemas/{slug}` and the commit response return `contract`. The schema-editor proposal body is still accepted and still cannot set either declaration. `GET /v1/schemas/{slug}/brains` lists `changedKinds` and `changedFields`, and `GET /schema-lineage`’s drift lists `changedKinds` and `changedFieldsByKind`, where the baseline declares a kind or field differently. A rollout applies a changed declaration to tracking Brains, and reports `failed` for a Brain whose review declarations it would lower. See [your organization’s own schema](/docs/schemas/overview/#your-organizations-own-schema). **Changed:** `PATCH /v1/brains/{handle}/schema` can set, tighten, or loosen a field’s `multiplicity` and `consequence` (it refused both). The Brain stores exactly the contract admitted, and `declaredVersion` in the answer is that contract’s version. Loosening a review declaration is admitted like a destructive change: from a Brain key it waits in review. See [review declarations](/docs/schemas/custom/#review-declarations). **Changed:** the `statement` kind and its links are on every Brain’s schema. `POST /v1/brains/{handle}/statements` works on any Brain (it answered `rejected`, `Unknown entity kind "statement"`, on a Brain that had not declared the kind), and applying a snapshot’s plan records a disagreement as a statement on any Brain. A schema that declares its own `statement` gets the platform’s. `GET /schema` lists the kind. **Changed:** an `admin` key may read `GET /v1/brains/{handle}/schema`, the schema it evolves; it still reads none of the Brain’s contents. The reference publishes the alternative as `x-nicia-brain-capability-alternatives`, and `@nicia/brain-client`’s operation metadata as `alternativeCapabilities`. **Fixed:** a record of a kind whose `identityField` is a declared field — the first way to declare one is the registry’s contract above — could be written but not read: `GET /records/{id}` answered `404` and `/query` nothing. Records are read at their id, as they are written. ## 2026-09-23 — Preview: the text a receipt pinned; applying a plan writes its records [Section titled “2026-09-23 — Preview: the text a receipt pinned; applying a plan writes its records”](#2026-09-23--preview-the-text-a-receipt-pinned-applying-a-plan-writes-its-records) **Added (preview):** `GET /v1/brains/{handle}/receipts/{receiptId}/documents` returns the exact text of every document version a receipt pinned, in the receipt’s order, even after the document was rewritten or retired, with a `contentHash` of each. Only the key that committed the receipt can read it; any other key gets `404`. Records the receipt pinned are not included. Each page is a read of its own: it commits a receipt of the new kind `document` and returns its id as `receipt`. Up to 25 documents per page. `@nicia/brain-client` gains `getReceiptDocuments`. See [Receipts](/docs/govern/receipts/#the-text-a-receipt-pinned). **Changed (preview):** applying a snapshot’s plan now writes what its documents state into your records, under the same decision as the documents they came from: new records and fields, changed values this source alone supported, and withdrawals of values nothing else supports — a field unset, or a record deleted when every value it held is withdrawn. A value a person or another source supports is never overwritten: a contradiction is staged for review as a statement of what the source said and a separate proposed value, and a reviewer selects one or both. Statements are written only in a Brain whose schema has the Standing Answers kinds. `GET /v1/brains/{handle}/changes/{changeId}/lifecycle` names, on each operation, the `record` (`{ kind, id }`) it writes or the `document` (`{ source, id }`) it serves or retires. See [Snapshots](/docs/write/snapshots/) and [Sources](/docs/write/sources/), both new. **Corrected:** these pages said a receipt “still resolves to the text that was actually shown” and that `GET /documents/{id}` serves a document’s text. The document read returns status and version, not text; the text a receipt pinned is the read above. This brings the registered v2 Brain surface to 68 operations. Preview — source snapshots may change without notice The source and snapshot operations and the receipt text read are preview: they work, but their shape may change or be removed. ## 2026-09-23 — Preview: apply a snapshot’s plan; receipts name the documents they served [Section titled “2026-09-23 — Preview: apply a snapshot’s plan; receipts name the documents they served”](#2026-09-23--preview-apply-a-snapshots-plan-receipts-name-the-documents-they-served) **Added (preview):** `POST /v1/brains/{handle}/sources/{name}/snapshots/{snapshotId}/apply` with the `planRevision` you read decides the plan document by document under the Brain’s admission policy. Admitted documents are served at once; the rest are staged in one review change (`review.changeId`, the id `/changes/{changeId}` reads) or returned in `rejected`. A document missing from a `complete` snapshot is retired only through that decision: at the default rung an agent source’s retirement waits for review, and until a reviewer publishes it the document keeps being served. A retired document’s earlier versions stay readable by the receipts that pinned them. Repeating the apply of the same revision returns the same body. `409` whose message starts `snapshot_plan_stale` means what the source serves, or the Brain’s schema, changed since the plan: seal again and re-read it. Applying a later snapshot recomputes an earlier snapshot’s still-pending review for the documents the later one covers. A snapshot now reports `applied`, or `awaiting_review` with the `reviewChangeId` it waits on. `@nicia/brain-client` gains `applySourceSnapshot`. **Changed:** a receipt’s `snapshot` now carries `evidenceSetDigest`, naming which documents, at which versions, the Brain served for that read. Writing a document — through a snapshot or `POST /documents` — changes it without changing `stateVersion`, so two receipts at one `stateVersion` can now differ. A receipt item for a document of a named source carries `document: { source, id }` and uses your document id as `source`. This brings the registered v2 Brain surface to 67 operations. Preview — source snapshots may change without notice Applying a snapshot is preview, like the rest of the source and snapshot operations: it works, but its shape may change or be removed. ## 2026-09-23 — Preview: snapshot plans read your schema [Section titled “2026-09-23 — Preview: snapshot plans read your schema”](#2026-09-23--preview-snapshot-plans-read-your-schema) **Changed (preview):** sealing a snapshot of a Brain whose schema declares fields now reads every added or changed document into those fields. The snapshot is `extracting` until the last document is read, then `planned`; a Brain whose kinds declare no fields plans at once, as before. `GET /v1/brains/{handle}/sources/{name}/snapshots/{snapshotId}?plan=true` now returns each document’s content hashes and the record fields it states, with the value, the quote it was read from, and how it compares with what the Brain has accepted: `new`, `restated`, `changed`, `contradicts`, `disagrees`, `withdrawn` or `unknown`, and who supports the accepted value. `?findings=true` pages what the snapshot’s own documents say about each other: `duplicate`, `disagrees`, and — on a field declared `single` — `contradicts`. A plan or findings page commits a receipt of the new kind `plan` and returns its id as `receipt`; polling a snapshot’s status commits none. Resealing a planned snapshot reuses every document already read. Applying a plan still decides only its documents: the record fields a page shows are not yet written. Preview — source snapshots may change without notice The source and snapshot operations are preview: they work, but their shape may change or be removed. ## 2026-09-22 — Preview: source snapshots [Section titled “2026-09-22 — Preview: source snapshots”](#2026-09-22--preview-source-snapshots) **Added (preview):** a source is your key plus a name you choose, such as one customer’s content. `POST /v1/brains/{handle}/sources/{name}/snapshots` opens a snapshot of it. `POST /v1/brains/{handle}/sources/{name}/snapshots/{snapshotId}/documents` adds up to 100 Markdown documents at a time, each acknowledged `new`, `changed` or `unchanged` against what the source serves. `POST /v1/brains/{handle}/sources/{name}/snapshots/{snapshotId}/seal` with `completeness: "complete"` or `"partial"` compares the snapshot with what the source serves and answers `202`. `GET /v1/brains/{handle}/sources/{name}/snapshots/{snapshotId}?plan=true` pages the plan: each document `added`, `changed`, `unchanged` or, for a complete snapshot, `missing`. Entries name the document and its class only; versions and content hashes are omitted until plan reads are receipted. `POST /v1/brains/{handle}/sources/{name}/snapshots/{snapshotId}/cancel` ends one, and `GET /v1/brains/{handle}/sources` lists every source with its owner. **Nothing in a snapshot is served yet.** Its documents are stored but do not appear in `/context`, `/search`, `/query` or the document listing; applying a plan is not available yet. Only the key that opened a snapshot can add to, seal, read or cancel it; any other key gets `404`. An open snapshot expires after 24 hours and a planned one after 7 days. `@nicia/brain-client` gains the matching preview methods. This brings the registered v2 Brain surface to 66 operations. Preview — source snapshots may change without notice The source and snapshot operations are preview: they work, but their shape may change or be removed. ## 2026-09-22 — Record search is full-text, large Brains are served, and publish refuses a moved base [Section titled “2026-09-22 — Record search is full-text, large Brains are served, and publish refuses a moved base”](#2026-09-22--record-search-is-full-text-large-brains-are-served-and-publish-refuses-a-moved-base) **Publish refuses a change whose records moved (breaking).** `POST /v1/brains/{handle}/changes/{changeId}/lifecycle/publish` re-checks every record the change was staged against. If any was edited, created or deleted since staging, nothing is applied: the answer is `409 conflict` naming each record that moved, and the lifecycle’s `application` becomes `blocked` with `blockedReason: "candidate_base_moved"` and `blockedRefreshAction: "restage_against_current_state"`. Approving again does not help; stage the change again against the current records. Any client that publishes must handle the `409`. See [Review](/docs/govern/review/). **Changes staged before this release were discarded.** A change that was pending review when this release deployed no longer exists; propose it again. **`/search` matches records with full-text search.** A record now matches when it holds every word of `q`, compared by English stem, with web-search syntax: `"quoted phrases"`, `or` between alternatives, and `-word` to exclude. A word no longer matches inside another word (`direct` does not find `redirect`). A match in a record’s name outranks one in its fields, which outranks one in its prose. See [Search and queries](/docs/read/search/). **Large Brains are served, not refused.** `/context`, `/search`, `/query` and the record listings select what they read in the database, so a Brain of any size is answered rather than refused with `400`. `/context` picks its candidates by the prompt and fills the budget from them; `omitted` counts every source not rendered, including ones never selected. See [Context](/docs/read/context/). **`/query` pages in codepoint `slug` order** — byte order, so `a-c` sorts before `ab`. A cursor from a walk started before this release was taken in a different order: restart that walk from the first page. **A staged deletion is its own lifecycle operation.** `GET /v1/brains/{handle}/changes/{changeId}/lifecycle` lists it beside the record writes, and `select` can keep or set it aside like any other operation. **Revoking a key takes effect on the next request.** A request authenticates once, at its start; every request after the revocation is refused. ## 2026-09-21 — `Authorization: Bearer` is the only scheme [Section titled “2026-09-21 — Authorization: Bearer is the only scheme”](#2026-09-21--authorization-bearer-is-the-only-scheme) **`x-api-key` is refused outright, on every `/v1/*` request** — `401 unauthorized` before any credential is even looked up, whether the value is an organization key, a Brain key, or a personal key. Send `Authorization: Bearer nsk_…` (an organization key) or `Bearer nbk_…` (a Brain key) instead. **Personal API keys are gone.** The console no longer mints them, and one minted before this change no longer authenticates anything on `/v1/*` — there is no header left for it to reach. An organization key, minted from **Settings → Organization keys** or `POST /v1/keys`, is the credential for a backend, a script, or an agent working across a whole organization. **`x-organization-id` still exists, for one case only.** A management key minted from `/v1/keys` is bound to its organization and never needs it. A management key minted before that binding existed carries none, and still requires the header on every call — see [Authentication](/docs/start/authentication/). ## 2026-09-21 — One vocabulary: change, statement, finding [Section titled “2026-09-21 — One vocabulary: change, statement, finding”](#2026-09-21--one-vocabulary-change-statement-finding) **Renamed (breaking), with no alias.** A request that uses an old name is refused as an unknown field or path, and a response no longer carries the old name. | Was | Is | | ------------------------------------------------------------------ | --------------------------------------------------------- | | the `…/observations` and `…/records/{id}/observations` paths | `…/statements` and `…/records/{recordId}/statements` | | `observationIds`, `observations` on a statement write or a finding | `statementIds`, `statements` | | `conflictId` in `/findings/{conflictId}` | `findingId` | | `changeSetId`, `successorChangeSetId`, `remainderChangeSetId` | `changeId`, `successorChangeId`, `remainderChangeId` | | `adoptedChangeSetId`, `candidateRevision` on a lifecycle | `adoptedChangeId`, `revision` | | `proposalId`, `proposalItemId` in a `review_required` outcome | `changeId`, `changeItemId` | | `{id}` in record, document, receipt and change paths | `{recordId}`, `{documentId}`, `{receiptId}`, `{changeId}` | **`Idempotency-Key` is the only place a retry key goes.** The key mint, schema promote and evolve, and the two definition writes used to take an `idempotencyKey` in the JSON body; that field is gone, and a key minted with one in the body is refused because the mint body is strict. Send the header, as on every other write. **A batch can carry a precondition per record.** Each record in `POST /batch` takes an optional `ifMatch`: the `token` you last read for it. Every precondition is checked before anything is written, so one stale token refuses the whole batch with `409 conflict` naming that record, and nothing is applied. A malformed `ifMatch` is a `400`, and a record that does not exist yet cannot carry one. The response returns every written record’s new `token`, so the next batch chains without a re-read. `knowledge_write_records` takes the same field. **A receipt describes the read, not the credential’s plane.** `connectionId` and `purpose: "ask"` are gone; `key` already names who read. A receipt item’s `version` is the record or document revision it was served at. **Migrating:** rename the fields and paths above, move `idempotencyKey` to the header, and drop any read of `receipt.connectionId` or `receipt.purpose`. `@nicia/brain-client` renames to match: `reportObservations`, `listObservations` and `withdrawObservation` are now `writeStatements`, `listStatements` and `retractStatement`. ## 2026-09-22 — Brain MCP follows the current protocol surface [Section titled “2026-09-22 — Brain MCP follows the current protocol surface”](#2026-09-22--brain-mcp-follows-the-current-protocol-surface) The Brain MCP endpoint now supports Client ID Metadata Documents for modern MCP clients while retaining dynamic client registration for clients that still use it. Every MCP tool publishes an output schema, and successful structured results are validated against that schema before they are returned. Tool calls now have a per-credential safety budget of 120 invocations per minute. When that budget is exhausted, the endpoint returns `429` with `Retry-After`; discovery and tool-list requests do not consume the budget. The same five tools are now easier for smaller models to use without hidden prompt knowledge. Every input schema includes a valid example; context and query explicitly distinguish semantic retrieval from exact filtering; and the two write tools return a stable `action` describing whether knowledge changed, review is required, or indexing is still in progress. Record writes name the `declaredVersion` they validated against. Safe failures carry stable recovery codes and the next tool when one is required. A compact `mcp-surface-usability` harness matrix now compares five model rows on eight deterministic cases covering tool selection, schema-first writes, idempotency, concurrency tokens, staged outcomes, and error recovery. ## 2026-09-21 — The Brain MCP endpoint is addressed by handle [Section titled “2026-09-21 — The Brain MCP endpoint is addressed by handle”](#2026-09-21--the-brain-mcp-endpoint-is-addressed-by-handle) **Changed (breaking):** the Brain MCP endpoint is now `https://api.nicia.ai/mcp/brains/{handle}`. The old `…/mcp/brains/{brainId}/connections/{connectionId}` is gone, with no alias: a client still pointed at it gets a `404`. The endpoint takes the same `nbk_` Brain key as every other Brain route, sent as `Authorization: Bearer`, and the key decides which Brain it may reach — a handle that names another Brain’s answers the same `404` as one that does not exist. The opaque Brain id works in place of the handle. **Any Brain key works, whatever client it was minted for.** The endpoint no longer requires the key to have been minted for MCP. Its tools follow the key’s capabilities: `read` offers `knowledge_schema`, `knowledge_context` and `knowledge_query`, and `write` adds `knowledge_write_document` and `knowledge_write_records`. **A key writes as an agent.** A key you mint over the API, or with the Connect dialog, is a program’s credential, not you: its writes are governed by the Brain’s mode, so on a `reviewed` Brain they stage for review even when a person minted the key. Writes a person makes in the app still land. **OAuth signs in to the same URL.** A client that speaks MCP OAuth can be given that URL with no credential and opens a browser. The token it receives is recorded as a key on that Brain, named `OAuth: `, with exactly the `read` and/or `write` scopes approved on the consent screen. A handle that names a Brain in more than one of your organizations is refused; use the Brain’s id. **Migrating:** press **Connect** in the Brain’s page for the new URL and a key, or change the URL in your client’s config to `/mcp/brains/{handle}`. The key you already have keeps working if it holds the capabilities you need. ## 2026-09-21 — The id-addressed plane is gone; the handle-addressed one is the whole API [Section titled “2026-09-21 — The id-addressed plane is gone; the handle-addressed one is the whole API”](#2026-09-21--the-id-addressed-plane-is-gone-the-handle-addressed-one-is-the-whole-api) **Removed (breaking), with no alias.** Every route addressed by a Brain’s internal id — credential create/list/rotate/revoke, the handshake, the mutation-batch and evidence writes, and the policy reads — is gone, along with the eight-name capability vocabulary those routes used and the webhook registration that was never wired to any of them. A request to any of the old `…/{brainId}/…` paths now gets a `404`. `@nicia/brain-client` drops the class that spoke them and the capability-preset constant derived from that vocabulary. The four-name capability vocabulary (`read`/`write`/`review`/`admin`) — already the only one `PUT /v1/brains/{handle}` and everything under it ever used — is now the only one there is. **Migrating:** provision on a handle (`PUT /v1/brains/{handle}`), mint a `nbk_` Brain key (`POST /v1/brains/{handle}/keys`) in place of the old credential, and call `documents`, `records`/`batch`, `statements`, `findings`, `changes`, `context`/`query`/`search`, `receipts`, `schema`, and `exports` — every one addressed by the handle, every one taking `Authorization: Bearer` with either that key or the organization’s management key. There is no automated migration: the old credential does not carry over, because the plane it authenticated is gone. ## 2026-09-21 — Export the whole Brain over the API [Section titled “2026-09-21 — Export the whole Brain over the API”](#2026-09-21--export-the-whole-brain-over-the-api) **Added:** `POST /v1/brains/{handle}/exports` starts an export and answers `202` with a `queued` export; `GET /v1/brains/{handle}/exports/{exportId}` reports its state; `GET /v1/brains/{handle}/exports/{exportId}/download` streams the `.zip` once it is `completed`. A repeated `Idempotency-Key` on the start returns the export the first request created. `@nicia/brain-client` gains `startExport`, `getExport`, `waitForExport` and `downloadExport`. **The archive is the whole accepted Brain, not only its documents:** every document at the address you gave it, every typed record as Markdown under `records//.md` beside `records.json`, `evidence.json`, the complete revision history, and a manifest whose root hash covers the documents and their history. Changes still waiting in Review, read receipts and the operation ledger are not included. **Export needs `read`, not `admin`.** It carries the whole Brain out, so it sits on the capability that already reads it, and an `admin`-only key is refused. Webhooks still have no Brain-key endpoint. Only the key that started an export can read or download it; another key’s export id, or one from another Brain, is a `404`. This brings the registered v2 Brain surface to 59 operations. ## 2026-09-21 — The Brain MCP server is five tools [Section titled “2026-09-21 — The Brain MCP server is five tools”](#2026-09-21--the-brain-mcp-server-is-five-tools) **Removed:** the question-answering tool, the custom-context receipt tool, the evidence and mutation submission tools, the mutation-outcome read, and the two correction tools. A call to any of them is now an unknown tool. **The server offers `knowledge_schema`, `knowledge_context`, `knowledge_query`, `knowledge_write_document` and a new `knowledge_write_records`.** `knowledge_write_records` takes the same body as `POST /v1/brains/{handle}/batch` — `records` of `id`, `kind`, `fields` and `links`, plus an optional `intent` — and an optional `idempotencyKey` in place of the `Idempotency-Key` header. It answers with the same change: `applied`, `staged` with the change id, or `rejected` with the reason. It goes through the same service as the HTTP route, so a write means the same thing from either door. **Why:** the write tools spoke the substrate’s vocabulary (mutation kinds, base versions, snapshot ids, provenance blocks), which is more than an agent should have to get exactly right, and two of them existed only to read back what a write should have returned. Correcting knowledge is writing new knowledge, so there is no correction tool. Nothing on MCP approves a change: an agent proposes, a person decides. **`think` is gone everywhere.** The console’s Ask box now shows the context the Brain would serve for the question, with its citations, from the same read `knowledge_context` uses. No model is called, so it never writes an answer the Brain did not vouch for. ## 2026-09-21 — Preview operations are marked, everywhere [Section titled “2026-09-21 — Preview operations are marked, everywhere”](#2026-09-21--preview-operations-are-marked-everywhere) An operation can now be **Preview**: shipped and working, but not a stability commitment. It may change shape or be removed without notice. The API reference lists every preview operation under its own **Preview (unstable)** tag, marks it `x-nicia-stability: preview` and prefixes its summary and description; the TypeScript client carries the same marker in its manifest and its hover documentation. Preview today: every durable-branch operation, the three definition operations, and on the change lifecycle `select`, `schedule`, `cancel` and the journal read. Documents, records, statements, context, query and search, changes with approve, reject and publish, receipts, the schema, and keys are stable. Preview — this list may change without notice Operations join and leave the preview list as they are proven; each change is written down here. ## 2026-09-21 — Markdown import ingests documents and types nothing [Section titled “2026-09-21 — Markdown import ingests documents and types nothing”](#2026-09-21--markdown-import-ingests-documents-and-types-nothing) **The markdown import lands every file as a document.** The import provisions the Brain under its handle the way `PUT /v1/brains/{handle}` does, then submits each file as a document, addressed by its source path, with its path and frontmatter kept as document metadata. It no longer wires a graph, so it writes no records: a source `type:` is metadata, and typing a document is a later, reviewed step by the Brain Agent. **The confirmation contract shrank to match.** The commit body no longer takes `typeMappings`, and the proposal no longer proposes `typeMappings` or `edgeFields`. The preview and the report drop `pagesTyped`, `pagesUntyped`, `unclassified`, `foldersDetected`, `foldersImported`, `nodeCount` and `edgeCount`; the report drops `sourceCorpusId`, and a Karpathy `raw/` tree is no longer split into a separate Sources collection. Each receipt entry now names its document with `evidenceId` and no longer carries `folderId`. A re-import versions a changed file in place, keeps a moved file’s address, and reports a file removed from the source without deleting its document. ## 2026-09-21 — Console page operations address a page by its slug [Section titled “2026-09-21 — Console page operations address a page by its slug”](#2026-09-21--console-page-operations-address-a-page-by-its-slug) **Rename, delete and the deletion plan take a `slug`, not an `artifactId`.** The console’s rename, deletion-plan and delete operations now name the page by its stable address. A page is a native record, and a record has no artifact to point at. **Rename changes the title only.** The address is the page’s identity — receipts cite it and `[[wikilinks]]` resolve to it — so a rename never moves it and no inbound link needs patching. The response reports the `slug`, the `previousTitle`, the new `title`, and whether the change `adopted` or is waiting on review. **Delete is one governed write.** The record leaves accepted state and the chosen link edits (`keep`, `remove` or `repoint`) land in the same batch; the Brain’s admission policy decides whether it lands now or waits for review. The plan now also lists `backingDocuments`, the evidence documents the page cites, which a deletion keeps. The result carries a `changeId` (the batch that landed, or the proposal waiting on review) in place of `changeSetId`. **Cloning copies native records.** A clone claims a new Brain and replays the source’s records into it under the same slugs. Evidence documents stay with the Brain that received them. **Build-from-corpus is removed.** The corpus “build a brain” flow and its job polling are gone, along with the per-Brain option to re-structure on source sync. Documents are evidence: an agent promotes them into records through review. ## 2026-09-21 — Approve and reject move onto the ChangeSet lifecycle [Section titled “2026-09-21 — Approve and reject move onto the ChangeSet lifecycle”](#2026-09-21--approve-and-reject-move-onto-the-changeset-lifecycle) **The approve and reject endpoints on a change are removed.** Deciding a change is now the lifecycle: `POST /changes/{id}/lifecycle/approve` binds the exact candidate and takes no body, `POST /changes/{id}/lifecycle/publish` applies it (send an `Idempotency-Key`; a retry returns the committed outcome), and `POST /changes/{id}/lifecycle/reject` requires a `reason`, which `GET /changes?status=rejected` reports back. **Partial approval is `select`, and it is per record.** `GET /changes/{id}/lifecycle` lists a change’s operations; `POST /changes/{id}/lifecycle/select` splits them into a successor you approve and publish and a pending remainder, and supersedes the original. The `records`, `fields`, and `edits` body on approve is gone: a field cannot be selected on its own, and a value cannot be edited on the way through. `partially_accepted` is no longer produced. Preview — partial approval may change without notice `select` is a preview operation, like scheduling, cancelling and the journal read on the same lifecycle. ## 2026-09-19 — Infer governed definitions from documents and reported counts [Section titled “2026-09-19 — Infer governed definitions from documents and reported counts”](#2026-09-19--infer-governed-definitions-from-documents-and-reported-counts) `POST /v1/brains/{handle}/definitions` infers competing definition candidates from Brain documents and counts reported by the authenticated producer. The server pins the accepted snapshot, preserves unknown membership, grounds counts only to matching concepts, and reports facts that would distinguish competing rules. `GET /definitions` inspects immutable candidates and `POST /definitions/{candidateId}/review` selects, approves, or rejects one through the authoritative ChangeSet lifecycle. Accepted definitions are versioned, machine-readable records. Both writes require idempotency keys. This brings the registered v2 Brain surface to 57 operations. Preview — definitions may change without notice The definition operations are preview: they work, but their shape may change or be removed. ## 2026-09-19 — Durable branches and selective publication [Section titled “2026-09-19 — Durable branches and selective publication”](#2026-09-19--durable-branches-and-selective-publication) A Brain can now fork a durable experiment branch from its exact current default origin, author records on it, and ask the server to derive a merge plan from the branch’s committed state. Planning stages that plan as an authoritative ChangeSet candidate; approval, optional scheduling, and publication use the same lifecycle as every other governed change. The new operations list, create, inspect, write, plan, archive, restore, and destroy branches under `/v1/brains/{handle}/branches`. This brings the registered v2 Brain surface to 53 operations. Preview — branches may change without notice Every branch operation is preview: it works, but its shape may change or be removed. ## 2026-09-18 — Record concurrency moves from an integer version to an opaque token [Section titled “2026-09-18 — Record concurrency moves from an integer version to an opaque token”](#2026-09-18--record-concurrency-moves-from-an-integer-version-to-an-opaque-token) A record’s optimistic-concurrency authority is now an **opaque, branch-scoped token**, not the integer `version`. Reads return it as `record.token` and mirror it as a strong `ETag`; writes take it in `If-Match` (the same quoted value). `record.version` and `written record.version` are gone. The token identifies the Brain generation, the active branch and its incarnation, the record and its incarnation, the editable revision, and the relevant schema identity. A token from one branch never authorizes a write on another, and `A → B → A` edits or delete/recreate do not revive an old token. A stale or cross-branch token is `409 conflict`; a malformed header — an integer, an unquoted value, a weak tag, the `*` wildcard, or a list — is `400 validation` naming the header. `DELETE` now honours `If-Match` too. Receipt `items[].version` remains an integer served-version pin and is not a write precondition. ## 2026-09-15 — Corrections address a permanent position, and “who was told” is retired [Section titled “2026-09-15 — Corrections address a permanent position, and “who was told” is retired”](#2026-09-15--corrections-address-a-permanent-position-and-who-was-told-is-retired) `change.id`, `correction.correctsChangeId`, and every `outcome.changeIds` entry now name a permanent Brain-log position (`{brainId}:{position}:{entryIndex}:{effectIndex}`) instead of a row id from the retired internal change-event table. Anything that stored an old-style id stops resolving; re-read the change from `/v1/brains/{handle}/changes` or a fresh write’s own response. **The `affected-reads` correction endpoint is removed** — formerly a GET on the correction’s `affected-reads` sub-path — along with `observedConsumers[]`/`candidateRuns[]` on the `knowledge.corrected` webhook payload. Answering “who was told” needed a scan this surface has no substitute for yet. `GET /receipts/{id}/now` still tells you, per receipt, whether what it cited has since changed — write the correction, then poll the receipts you already hold rather than asking the Brain to enumerate every consumer. This brings the registered v2 Brain surface to 45 operations. ## 2026-09-15 — Brain keys can be renamed and granted new access [Section titled “2026-09-15 — Brain keys can be renamed and granted new access”](#2026-09-15--brain-keys-can-be-renamed-and-granted-new-access) `PATCH /v1/brains/{handle}/keys/{keyId}` updates a key’s name and capabilities. Capability changes take effect for its live credentials on the next request; the secret does not need to be replaced. Brain Settings now links to key management, and the Connect dialog shows name and access choices before minting. This brings the registered v2 Brain surface to 39 operations. ## 2026-09-02 — A correction says who was told [Section titled “2026-09-02 — A correction says who was told”](#2026-09-02--a-correction-says-who-was-told) *Removed 2026-09-15 — see that entry above.* A GET on the correction’s `affected-reads` sub-path answers who was served the content a correction superseded, at the handle, under a `read` Brain key. `eventId` names the corrected change, not the correction. `observedConsumers[]` is receipt-backed — each entry is a read that provably happened — and `candidateRuns[]` is the separate, weaker claim about runs the Brain was merely available to; the two are never unioned, and `truncated` says when the observed set is a floor rather than a total. An empty answer means no receipted read of the superseded content, not that nobody could have seen it. This brings the registered v2 Brain surface to 45 operations. The `knowledge.corrected` webhook payload now carries the same `observedConsumers` list, capped at 50 entries and bounded by what was observed; past the cap, read the endpoint above. It has the same 50-entry cap and reports `truncated` with the observed floor; it does not offer a continuation cursor yet. `knowledge.corrected` is also subscribable per key now, not only organization-wide. A correction reaches the keys whose own receipted reads observed the superseded content, plus any key on that Brain that asked to hear about corrections at all. Key matching is exact — there is no organization-wide or Brain-wide fallback on that scope — and a per-key registration is not also delivered through the organization-wide `/v1/webhooks` path, so one correction is never two deliveries to one URL. ## 2026-08-31 — Record history is explicit about time [Section titled “2026-08-31 — Record history is explicit about time”](#2026-08-31--record-history-is-explicit-about-time) `GET /v1/brains/{handle}/records/{id}/history` now returns the accepted audit transitions for one record, newest first, with deletion, immutable revision numbers, actor attribution, and both directions of correction lineage. A correction that writes a different record still carries enough transition metadata to inspect it from the original record’s history. The endpoint deliberately returns no historical field values. `changedAt` is the append-only audit-event timestamp, not a coordinate that reconstructs past accepted state; generic `at` and `asOf` remain unsupported. Its `availability` means only whether that record was present after the event, not the Brain’s separate active/held/deleted lifecycle. Receipts remain the proof of exact values served to a caller, and statement valid time remains explicit on the statement itself. ## 2026-08-31 — Statements and conflicts are served [Section titled “2026-08-31 — Statements and conflicts are served”](#2026-08-31--statements-and-conflicts-are-served) Statements are served: a schema-optional literal or record reference claim can be written (under the path then named `/observations`, now `/statements`), a record’s retained history read back, and one retracted while retaining its historical value and provenance. Disagreement from overlapping source claims is available through `GET /findings`; `POST /findings/{findingId}/decide` keeps one statement or records a corrected value without deleting the losing evidence. These five operations are now in OpenAPI and the generated TypeScript client, bringing the registered v2 Brain surface to 37 operations. `/context` names contested facts inline — `warnings[]` kind `contested` plus per-item `status`. There is no `conflicts[]` field on the read; `GET /findings` is the operator inbox. ## 2026-08-31 — Brain schemas are discoverable and governed [Section titled “2026-08-31 — Brain schemas are discoverable and governed”](#2026-08-31--brain-schemas-are-discoverable-and-governed) **The public Brain schema loop is live.** `GET /v1/brains/{handle}/schema` returns the complete governed `DeclaredSchemaContract` and separately verifies whether its queryable materialization is `ready` or `drifted`; the read never repairs drift. `GET /schema/candidates` reports snapshot-keyed kind counts, declared-field coverage, and recurring undeclared keys. `PATCH /schema` and `POST /schema/promote` create governed Review proposals against an exact schema and Brain snapshot. They do not rewrite source documents or bypass the one `evolve_schema` mutation path; Review acceptance reports success only after the accepted contract verifies ready. Structured record queries now fail closed while the governed contract and its materialization disagree. Document reads remain available, because evidence is not discarded merely because its structured projection needs repair. ## 2026-08-27 — `/context` retrieves, sign-up opens, and the context check ships [Section titled “2026-08-27 — /context retrieves, sign-up opens, and the context check ships”](#2026-08-27--context-retrieves-sign-up-opens-and-the-context-check-ships) **`/context` ranks against your prompt.** It used to order every record by id and fill the token budget from the top, so the answer to any question was whichever sources sorted first alphabetically — the `prompt` was recorded on the receipt and never used to choose anything. It now ranks two ways at once: a record scores because a **declared field** holds something your prompt named, and because the **passage index** ranked its text. Neither penalizes the other, so a typed record with no prose is retrievable on its fields alone and a document is retrievable on wording alone. Sources nothing matched keep a stable order, so the tail stays deterministic. Three consequences worth knowing. A prose record too large for the budget now contributes the **passages your prompt matched** instead of being skipped whole, and the receipt names those passages. Where a deployment configures a **reranker**, a model may re-judge the top of the list — so every answer’s snapshot now carries the retrieval models it was served under, and `none` when no model ran. And one oversized document can no longer empty an answer it happens to lead. See [Request context](/docs/read/context/). **Sign-up is open — no invitation code.** Signing in needs a verified email address, and the sign-up endpoints refuse a request that did not come from the product frontend. [Authentication](/docs/start/authentication/) and the [Quickstart](/docs/start/quickstart/) describe the current flow; nothing changed for existing keys. **The context check ships in the product.** Open a served context — the thing a [receipt](/docs/govern/receipts/) records — and run a check that reports where the sources behind that answer contradict each other, where wording was duplicated, and where the payload carried no information. Contradiction findings pass a live-model judgment before they are shown. It is a report over reads you already made, not a new field on the read and not a gate in front of one — the `/context` response shape is unchanged. ## 2026-08-26 — Organization schemas resolve, and revert stops overclaiming [Section titled “2026-08-26 — Organization schemas resolve, and revert stops overclaiming”](#2026-08-26--organization-schemas-resolve-and-revert-stops-overclaiming) **A bare `schema` slug, and an inline schema’s `extends`, now resolve against this organization’s own registry.** Before tonight, `PUT /v1/brains/{handle}` checked only the two built-ins, `nicia-base` and `nicia-core`. Both fields now check built-in first — a tenant registration can never shadow a shipped pack — and fall back to whatever this organization has registered with `POST /v1/schemas`. On an inline `extends`, the caller’s kinds merge over the named base and the base wins any name collision. A slug that resolves to neither is a `400` naming the field and listing the built-ins. On a bare `schema` that refusal is scoped to a create or a revive, so a re-`PUT` that echoes back a Brain’s own per-Brain inline slug stays legal; on `schema.extends` it applies to every `PUT`, because an inline schema is re-merged over its base on each one and an unresolvable base yields no vocabulary at all. No domain pack shipped tonight — the maintained CRM, support, and research packs [the reference describes](/docs/schemas/overview/) still do not exist; what changed is that your own registrations resolve. **`GET /v1/brains/{handle}/schema-lineage` answers which organization schema a Brain forked from, and whether that baseline has moved.** The response is `{ "lineage": { "status": … } }`, one of `unrecorded` (nothing was ever stamped — not the same as “no drift”), `independent`, `in-sync`, `base-moved` (carrying `currentBaseHash` and an optional `drift` of missing kinds, edges, and fields), or `base-unresolvable`. It takes an organization key only, and answers `401` for a Brain key: this reports a fact about the organization’s schema registry, outside what a single Brain’s credential is scoped to. **`GET /v1/schemas/{slug}/brains` and `POST /v1/schemas/{slug}/rollout` are in the reference now.** Both sit on the organization `/v1/schemas` surface. The fleet read answers each Brain’s `alignment` — `tracking`, `diverged`, or `claimed` — against the schema, plus `behind` and the missing/local kind, field, and edge names. The rollout applies the schema to every Brain on it (or to an explicit `knowledgeGraphIds` list): a plain Brain has the contract evolved in place, a diverged Brain gets a ChangeSet staged for review instead of a silent write, and one Brain’s failure never aborts the sweep — the response counts `applied`, `offered`, `skipped`, and `failed` outcomes rather than stopping at the first one. Two skips are worth expecting: a Brain already carrying the contract is `up_to_date`, and a Brain nobody has written to yet is `claimed` — its first write materializes from the current organization contract anyway, so rolling it forward now would only force a materialization early. Both operations read at most 200 Brains, and an unrestricted read or sweep that hits that ceiling answers `truncated: true` rather than presenting a partial fleet as the whole one. **Every Brain response now includes `generation` beside `id`.** It is the fence every generation-scoped write — evidence submission, mutation submission, policy activation — makes you echo, and it was previously reachable only through a console-plane read that no page of this site names. Treat it as opaque and required: a revive or reprovision mints a fresh one, so re-read it rather than caching it forever. **A mutation whose `provenance.origin` is anything but `emitted` now says why.** Canonical mutation ingress has always accepted `emitted` only — that did not change, and the published schema still advertises `emitted | derived` deliberately, because `derived` is a legitimate origin on internal projection paths. What changed is the `400`: it now names `emitted` as the accepted value, quotes back what you sent, points at the offending mutation’s index in the batch, and says why a computed value still ships as `emitted` — a `derived` mutation is one reconstructed from a before/after diff, never stated by its own writer. **The reference names a host.** `https://api.nicia.ai` is where this API answers, and the OpenAPI document now both points its one `servers` entry at it and names it in the document’s own prose — a reader looking for where to send their key does not read a `servers` array, and a generated client is pointed at a real address instead of needing one supplied by hand. The docs’ examples already used this host; what’s new is the document saying so, and a check that keeps it honest: the published URL is pinned against the custom-domain route the API Worker actually serves, so the two cannot drift apart. The `workers.dev` hostname the same Worker also answers on is deliberately not published — it is a detail of the account it is deployed in, and an address that can move out from under you. **The documented error kinds are eight, not seven.** `unavailable` (503) joins the [table](/docs/api/errors/) — it was already on the wire, answered by a receipt-store outage, and is now the second transient kind alongside `rate_limited`. **Reverting a ChangeSet no longer reports an undo it didn’t do.** It used to mark a ChangeSet `reverted` while restoring nothing for page content or corpus membership. It now plans the revert before touching the set’s status: if every item can be expressed as a concrete, symmetric undo, it restores them and marks the set `reverted`; if any item cannot — corpus membership changes, schema evolution, and automation configuration have no supported automatic undo, and some page edits are missing the facts a restore needs — the whole revert is refused with a `409` and the set stays `adopted`, with the response pointing at rolling the affected pages back individually from their version history. **If you call revert today, this is the change to check for:** anything that treated a `2xx` from this endpoint as “undone” needs to handle the `409` instead. ## 2026-08-20 — A record read answers its `links` and its `undeclared` [Section titled “2026-08-20 — A record read answers its links and its undeclared”](#2026-08-20--a-record-read-answers-its-links-and-its-undeclared) **`links` and `undeclared` are populated on every record read.** `GET /records/{id}` and [`POST /query`](/docs/read/search/) have published both fields all along and emitted neither. A declared relationship came back inside `fields` as the raw string `"[[acme]]"`, so a caller who wrote `"links": {"works_at": ["acme"]}` had no way to read back what they had written, and a generated client’s `record.links` was `undefined` forever. Both now carry what the write path already knew: `links` maps each declared edge to the addresses it points at, and `undeclared` names the fields the schema does not declare for that kind. **Which means `fields` no longer carries relationship values.** If you were parsing `fields.works_at` out of `"[[acme]]"`, read `links.works_at` instead — an array, with each `[[…]]` target unwrapped to the address inside it. A target stored in some other form comes back as written rather than being dropped, which is the same leniency the write path applies: a link you can see is a link you can correct, and a silently shortened list would let a `PUT` clear an edge it never showed you. Nothing else moves: undeclared values still come back in `fields` exactly as stored, and `undeclared` only names them. Relationship-ness comes from the schema, never from what a value looks like, so a field whose value happens to read `[[support]]` stays a string unless an edge is declared over it. ## 2026-08-20 — A create can say it is a create [Section titled “2026-08-20 — A create can say it is a create”](#2026-08-20--a-create-can-say-it-is-a-create) **`PUT /v1/brains/{handle}` takes `ifExists`.** The default is `"update"`, and that is the operation you already have: the Brain at that handle is resolved first and your `name`, `purpose`, and `mode` are applied to it. Send `"ifExists": "conflict"` when the call is a **create** rather than a reconcile — a provisioning script standing a Brain up, or anything deriving a handle from a name a human typed. A taken handle then answers `409` naming the fields that differ, and nothing is written. A request that states nothing different from what is stored still answers `200` with that Brain, so retrying a create whose response you lost is still safe. This matters most where the handle is derived rather than chosen. Two people who both call a Brain “Support” derive `support`, and under plain create-or-update the second one silently renames the first one’s Brain and rewrites its purpose. **`GET /v1/brains/{handle}/records/{id}` answers `409` for an ambiguous address.** More than one record can end up at one address — a retype that leaves the old typed record behind is the usual way — and the addressed writes already refuse that case. The read used to answer one of them, chosen by an internal sort order with no relation to which is current, behind a receipt asserting that version was what the Brain held there. It now refuses too. [`POST /query`](/docs/read/search/) on the same predicate still returns both, which is how you see what happened and retire the stale one. ## 2026-08-20 — One create, one document [Section titled “2026-08-20 — One create, one document”](#2026-08-20--one-create-one-document) **`PUT /v1/brains/{handle}` is the only way to create a Brain.** A `POST` to the Brain collection used to answer too — a second create, on the same path the `GET` on this page lists, returning a differently-shaped object. One path taught two nouns split by method, and the published reference resolved that by hiding the second one, which meant the document you read and the document `@nicia/brain-client` is generated from were not the same document. They are now. That create moved to the console plane, where the platform record it answers already lives, and nothing is withheld from the reference any more. **If you called the old create:** send the same identity to `PUT /v1/brains/{handle}` — the `key` you chose becomes the handle in the path, and `name`, `purpose`, and `subject` are the same fields in the body. Two differences are worth knowing before you switch. This one is create-or-**update**: it resolves the Brain first, so re-sending a changed `name` renames it instead of answering the `409` the old create answered for a changed specification — send `"ifExists": "conflict"` to get that refusal back. And it defaults a new Brain to `mode: "open"`, which admits writes; state `"mode": "reviewed"` if you want them staged. Three things the old create accepted have no field here, deliberately: a Brain born over a collection of documents you already hold, a governance policy object, and a full declared schema at a slug you name. Those are console affordances and they stay on the console’s own create. What this one takes instead is `schema` — a pack slug, or an inline `{kinds, links}` vocabulary that is fully typed in the [reference](/docs/reference/). **`externalRef` is removed from the wire.** It was a deprecated alias for the Brain `key`, on the old create body and on the Brain list filter. Both are gone: filter with `?key=` and address a Brain by its handle. If you stored an `externalRef`, it is your key and it is a legal handle unchanged — the same value under a different field name. **One of the three is outbound, so check your receiver.** The `knowledge.corrected` webhook payload carried `data.externalRef` and now carries `data.key` — same value, same meaning, and it is still absent when the Brain was provisioned without a key. A receiver that maps a delivery to its own object through `data.externalRef` reads `undefined` on every delivery until it is changed; nothing about the requests you send will tell you, which is why it is called out separately here. ## 2026-08-20 — `GET /records/{id}` is live, and the preview label is retired [Section titled “2026-08-20 — GET /records/{id} is live, and the preview label is retired”](#2026-08-20--get-recordsid-is-live-and-the-preview-label-is-retired) **The addressed record read answers.** `GET /v1/brains/{handle}/records/{id}` returns one record at its own address. The last two entries on this page named it as the one registered operation without a route; this closes that loop. **Every operation the reference publishes is now one the server answers.** It is not a new way to read. It is [`POST /query`](/docs/read/search/) with the address as its only predicate — the same canonical reader, the same projection, the same receipt discipline — so the two can never disagree about one record. Which means it answers what `/query` answers, minus the page: `record` instead of `records`, no `nextCursor`, and the same `staged`, `warnings[]`, and `receipt` beside it. Keep the `receipt` and [`GET /receipts/{id}`](/docs/govern/receipts/) will tell you the exact version you were shown. **An address the Brain holds nothing at is a `404`.** The address is the identity here, and you asked for one record, so an empty envelope would leave “no such record” indistinguishable from “a record with no values”. That read is still receipted: the consumption log records that your credential asked for that address, because an audit of who went looking must not have holes where the answer was “nothing”. If you added `receipt` handling for `/context`, `/query`, or `/search`, this needs none of its own — it is the same field. **“Preview” is retired in favour of “Beta”.** The site banner, the [reference](/docs/reference/) banner, and the published OpenAPI document all carried a warning naming the operations specified ahead of the server, plus an `x-nicia-preview: true` marker on each so a generator could refuse to emit a method for them. There are none left, so the whole mechanism is gone rather than standing empty — including the extension field, which no operation carries any more. What remains is the beta policy it always sat beside, unchanged: this surface may change before GA, and every breaking change is dated here. ## 2026-08-20 — A Brain read carries its `id` again, and `PUT` accepts a `subject` [Section titled “2026-08-20 — A Brain read carries its id again, and PUT accepts a subject”](#2026-08-20--a-brain-read-carries-its-id-again-and-put-accepts-a-subject) **Every Brain response now includes an `id`.** The handle is still the address: every operation on this surface takes it, and none asks for an id back. But the older id-addressed paths — `/v1/brains/{brainId}/connections/...`, `/v1/knowledge-graphs/{id}`, and the capture, entities, schema-pack, and changeset paths hanging off them — are still the only address for capabilities this surface has no operation for yet, and a caller who provisioned through `PUT /v1/brains/{handle}` had no way to reach any of them. So the id comes back in `data.brain`, on the create, the get, the update, and every row of the listing. Store it only if you call one of those older paths; a client that ignores the field is a correct client. It goes away when those paths stop taking ids, and this page will say so on the day it does. **`PUT /v1/brains/{handle}` accepts a `subject`.** A subject is what the Brain is about — the thing it represents, as your system already names it: `{ "kind": "customer", "externalId": "acme", "label": "Acme" }`. It is optional, because nobody should have to invent one: omit it and the server derives a subject from the handle and the name, which is exactly what it did before. It is recorded when the Brain is created. A Brain’s identity is generation-bound, so a `subject` sent to a handle that already exists does not move it — which is the same reason this endpoint stays safe to call on every request. ## 2026-08-20 — The console mints organization keys; every example leads with `authorization: Bearer` [Section titled “2026-08-20 — The console mints organization keys; every example leads with authorization: Bearer”](#2026-08-20--the-console-mints-organization-keys-every-example-leads-with-authorization-bearer) **Settings → API keys has an “Organization keys” section.** Minting an `nsk_` management key used to require calling `POST /v1/keys` yourself — the console only ever created personal `app_` keys. It now mints, lists, and revokes organization keys directly, over the same `/v1/keys` surface these pages document; revoking disables the row rather than deleting it, so it stays listed for the audit trail, same as it does over HTTP. **Every management key minted this way is bound, so it never needs `x-organization-id`.** That was already true of an `nsk_` key minted over HTTP; it is now also true of one minted from the console’s Organization keys section, because both mint through the same route. Every management-key example on this site was rewritten to match: the call now sends `authorization: Bearer $NICIA_KEY` alone. `x-api-key` is still accepted for management keys, and one minted before `/v1/keys` existed carries no organization binding and still needs the header — see [Authentication](/docs/start/authentication/). This does not touch personal `app_` keys, minted from the same page’s “Your keys” section: they are a different plane, are not bound to an organization today, and still need `x-organization-id` on every write. Cutting unbound `app_` key acceptance is a later wave, gated on this mint flow baking in. ## 2026-08-20 — `/context` and `/query` now see every accepted document `/search` ranks [Section titled “2026-08-20 — /context and /query now see every accepted document /search ranks”](#2026-08-20--context-and-query-now-see-every-accepted-document-search-ranks) **A `text/plain` or `application/json` document is no longer invisible to `/context` and `/query`.** The read model that surfaces an unclassified page (one no typed kind has claimed yet) filtered on `mimeType === "text/markdown"` — narrower than what gets indexed for `/search`, which chunks and embeds anything `text/*`, `application/json`, or `application/yaml`, the same set the `url` and Google Drive sources accept as evidence. A `text/plain` or `application/json` document you submitted could rank at the top of `/search` while `/context` and `/query` answered as if the Brain had never seen it, with `warnings: []` — which the read contract defines as “this answer is complete.” Both surfaces now agree: any media type `/search` ranks, `/context` and `/query` show. This changes `/context` output for any existing Brain holding `text/plain` or `application/json` evidence: those documents now appear where they previously did not. A genuinely binary type (images, PDFs) is unaffected — it was never indexed by `/search` either, and stays out of both. **Only markdown gets its leading `---` block read as frontmatter.** Splitting a document into declared fields and prose is a markdown convention, and it is now applied to `text/markdown` alone. A `text/plain` note or an `application/yaml` payload that opens with `---` means those bytes literally: the whole payload is the prose, which is the same text `/search` returns for it. Until now the splitter ran on every text type, so a plain-text file’s first block was promoted into that record’s `label` and `fields` while `/search` still showed the fence — two different renderings of one document. `application/json` projects as prose the same way: the JSON text you sent is the text you get back. **`GET /documents` says what it lists.** It answers what you SUBMITTED, and it always has. Content that reached a Brain another way, such as a synced folder or URL source, has no submission to list and no document id to address, but it is read by `/context`, `/query`, and `/search` — and the widening above means more of it is. No behavior changed; the reference now states the boundary instead of leaving you to infer that the listing is the whole Brain. ## 2026-08-19 — Management keys are in the reference, and the preview banners tell the truth [Section titled “2026-08-19 — Management keys are in the reference, and the preview banners tell the truth”](#2026-08-19--management-keys-are-in-the-reference-and-the-preview-banners-tell-the-truth) **`POST /v1/keys`, `GET /v1/keys`, and `DELETE /v1/keys/{id}` are documented.** The mint, list, and revoke operations for the `nsk_` management key have been serving traffic, but they were missing from the [API reference](/docs/reference/) — so every other operation’s description told you to send a management key while nothing showed where one comes from. They are in the document now, with the one thing an integrator most needs to know up front: this surface takes a **signed-in browser session**, and refuses an `x-api-key` credential with `403 forbidden`, including a valid one. A key that can mint its own successor would outlive its own revocation. See [Authentication](/docs/start/authentication/). A key minted this way carries its organization in the credential, so `x-organization-id` is not needed on any call made with it. A key created in the web app does not, and still names its organization by header. **The preview banners now say what is actually unserved.** The site banner claimed “endpoints may not respond yet” and the reference’s banner said the v2 operations “mostly return `404`” — while 25 of the 26 registered operations had live routes. Both banners now derive their claim from the same list the document’s own summary and per-operation notices use, and name the operation: `GET /v1/brains/{handle}/records/{id}`, the one still specified ahead of its server. When it lands, the warning disappears from every carrier at once. ## 2026-08-19 — Govern is live: `/changes`, approve, reject, and both receipt reads [Section titled “2026-08-19 — Govern is live: /changes, approve, reject, and both receipt reads”](#2026-08-19--govern-is-live-changes-approve-reject-and-both-receipt-reads) The review queue and the receipt reads answer for real. `GET /records/{id}` is the one operation left across all six groups without a route — it still answers `404`, and its own description says so. **`GET /changes` pages one status at a time.** `staged`/`rejected` and `applied` are genuinely different feeds — a keyset cursor over staged and rejected proposals, and a separate keyset cursor over the applied change-event log — with no shared order between them, so a single cursor that silently mixed the two could hand back a page out of order or missing rows. Every `nextCursor` this endpoint returns names the status it continues, so you never have to restate it; omitting both `status` and `cursor` defaults to `staged`, the review queue. An `applied` row’s `id` is the same id you already hold from the write that produced it — the batch id an immediate admit’s own response returned, or the change id a settled proposal was approved under — so one write is addressable by one id across every page it ever appears on. **`records[]` on a change reports `before`, `after`, and cited `evidence`.** `before` is read from the mutation’s own frozen base version (`baseArtifactVersionId`) — the exact content the change was written against — never from live accepted state, so a reviewer sees the diff the change actually proposed even if the record has moved since it was staged. It is absent on a `create_entity` item, which has nothing to hold a `before`. `applied` changes carry no `records[]` at all — they are a light audit trail, not a review surface. **`approve` accepts a whole change, named records, named fields per record, or corrected values.** Naming `records` or `fields` is a decision about everything else in the change too: anything you did not name is explicitly declined in the same call, never silently admitted alongside what you did name. A partial approve settles the whole change at once, so declining is final for it — a later `approve` naming what was declined 409s, and the producer has to write again for it to come back up for review. `edits` re-validates each corrected field through the same canonical mutation path a normal write takes, and it is attributed to the reviewer, not the original writer. **Receipts join to an integer `version`, addressed by the item’s own document.** `GET /receipts/{id}` and `GET /receipts/{id}/now` both publish the exact numbered version a citation was cut from, not an internal version id, and both name the record’s own backing document — its evidence locator when one was cited, its own address otherwise — rather than an opaque internal id a citation was never supposed to expose. `receiptNow` reports a Brain under legal hold as `held`, alongside `superseded` and `deleted` — the three ways an answer can have changed since it was given. **The console’s own change report moved to a console-plane route.** It answers a different shape — the aggregated `byChangeType`/`byDay` rollup behind the workspace’s history tab, over a date range rather than a cursor — and `/v1/brains/{handle}/changes` is the public, cursor-paged queue. **Walking `?status=applied` sees each change exactly once.** One write mints one event per record it admitted, and a three-record batch whose events fell either side of a page boundary was listed on both pages — so anything counting “changes applied”, or rendering the queue, double-counted it. Each page’s cursor now carries the change it ended inside, and the next page suppresses it. **A partial approve answers with what it admitted.** Approving named `fields` on an UPDATE used to answer `status: "applied"` with an `after` containing the values it had just declined — the reviewer’s own permanent record of a final decision, wrong about both what landed and how much of it did. `after` on a settled change now carries only admitted values (absent for a record that was declined whole), and any partial admit reports `partially_accepted`, the same as one scoped by `records`. On a change nothing has settled — `staged`, `rejected` — `after` is still the full proposal, which is what a reviewer is being asked about. **`reason` on a rejected change is the reviewer’s reason.** It reported the policy reason that STAGED the change instead — “this Brain’s write policy requires review”, which is true of every reviewed write there has ever been — so the rejected queue answered a question nobody asked. It now reads the decision while a change is settled, and the staging reason only while it is still waiting. **`GET /changes` caps `limit` at 250, and says so.** It published 500 and served a silently smaller page, with nothing in the response to say it had been reduced. 250 is what the review reader slices to; 251 is now a `400` naming the bound. **A batch may name a record once.** Two entries for one id have no decidable order, and the write refused them — but as `200` with `{"status": "rejected"}`, the shape these pages define as “the Brain declined it; the request was fine, the content was not”. It is a `400` naming the id, like every other structural refusal on `/batch`. Merge the entries, or send two batches. **Re-sending a document is visible to the receipts that cited it.** Replacing a document under the same id promoted a new version that `GET /documents/{id}` reported — while `/context` kept serving the old text and `GET /receipts/{id}/now` answered `unchanged`, on precisely the correction that endpoint exists to surface. A replacement now moves the Brain’s own view of the document, and every receipt citing the old version reports `superseded` with the version that replaced it. **A receipt is readable only by the key that committed it.** That was always the behaviour; the reference and the receipts guide taught the opposite, showing the management key fetching a receipt a Brain key had produced, which `404`s. The guide now shows the Brain key, both receipt operations state the rule, and the `404` itself says why rather than reading as a wrong id. A receipt records which credential a read was served under — that binding is what makes it evidence — so neither the management key nor a second Brain key on the same Brain can read another key’s receipt. **`/query` refuses rather than inventing a version.** When a record’s pinned version did not resolve it answered `version: 1`, a number nothing measured, while the receipt behind that same read failed closed on the identical miss. Both now fail closed. **Five fields nothing ever set are gone.** `receipt.prompt` and `receipt.items[].quote` (a receipt names a request by hash and pins versions — it holds neither the prompt nor the passage text), `changed[].current` on `/receipts/{id}/now` (`source` + `currentVersion` is the address of the current text, through `GET /documents/{id}`), and `intent` and `fields` on a change as `GET /changes` and the review operations report it — both are echoes of a write request, and stay on the change a WRITE answers with. Every one was the undetectable kind of absence: optional, so a missing value read as “this one has none” rather than “nothing ever sets this”, and two of them appeared in shipped examples. `brain.documentCount`, `brain.recordCount`, and `document.chunks` stay declared — they are additive and the shape is right — but now say in the reference that no read sends them yet. ## 2026-08-19 — a read says which mode it ranked in, and stops answering with ids nothing accepts [Section titled “2026-08-19 — a read says which mode it ranked in, and stops answering with ids nothing accepts”](#2026-08-19--a-read-says-which-mode-it-ranked-in-and-stops-answering-with-ids-nothing-accepts) **`consumerId` is bounded on every read.** `/context` published the receipt’s own bound (1–512 characters); `/query` and `/search` published none, so a value the contract accepted failed underneath — `/query` answered `500` with the substrate’s raw validation error in the message, and `/search` answered `503 unavailable`, which is the retryable status this surface reserves for a receipt-store outage. Anything building `?consumerId=${userId ?? ""}` retried a permanent input error forever. All three now refuse it as a `400` naming the field. **`/search` says which mode it actually ranked in.** `hybrid` needs an embedding provider, and a deployment without one ranks by keywords instead of failing a read it can still serve — silently, with `warnings: []`, which these pages define as “this answer is complete”. The response now carries `mode`, and it is the mode the ranking was performed in, not the one that was asked for. **`staged` counts changes, and says so.** It always counted proposals awaiting review — one batch of twenty records is one change — while the reference called it a count of records, so a caller who wrote twenty was told `1`. The word now matches the number. Two adjacent claims are corrected with it: `staged` is not always `0` on an `open` Brain (anything that proposes rather than writes puts work in the queue whatever the mode), and it can now be **`null`**, meaning the count could not be taken on that read. It used to report a failed count as `0`, which is this surface’s own way of saying “the Brain knows nothing relevant” — the exact ambiguity `staged` exists to remove. A `processing` warning whose count fails is likewise omitted rather than reported as zero. **`sources` names document ids, or nothing.** For a record written through `PATCH /records/{id}` it answered the record’s own internal artifact id — a 21-character token `GET /documents/{id}` refuses — because such a record cites its own stored page, which never arrived as a document. A source with no public address is now omitted. **`fields` no longer restates `kind`.** A record read carried a `type` field holding its own kind: the frontmatter key the capture boundary writes, echoed back as if it were one of your values, and already published as `kind`. **A kind you declare called `document` is your kind.** `document` is also the name the reader stamps on a page nothing has typed, and it used to decide between the two by matching that name (and `note`, `doc`, `page`) — so a Brain declaring `document` with fields of its own had every typed record served as a markdown blob, with the `record` branch of the published item union never running. The split is now made against your declared schema: a piece is a document when it is filed under the kind your Brain uses for things nobody has typed (your own fieldless free-form kind, or `document` when you declare none), and a record otherwise. **`reviewUrl` is removed from `/context` and from `change`.** It was published on both and emitted by neither, and an optional field nothing sets reads as “no review page for this one” rather than as “this never works”. Nothing in the API can build the link honestly today — there is no configured console address for it to be built from. It returns when there is. **What a document contributes to `text`, stated correctly.** These pages said a document contributes “its prose, and nothing else”. A document that carries frontmatter contributes a name line and every frontmatter key as a `key: value` line as well — an authored field is never dropped, in a prompt any more than in storage. Worth knowing before you paste: a document carrying an `internal_owner:` line puts that line in front of your model. `citations[].quote` is still the block, character for character. **The `slug` in these examples is the shape the API produces.** Citations and search hits showed `hubspot-deal-412` beside their `source`; a document written through this API is addressed internally by a content hash (`brain-evidence-<64 hex characters>`), never by a slugified title. `source` — your own id — is the address to act on, and `slug` is for lining a citation up against a search hit. ## 2026-08-19 — `/context` hands back prose, and a document is never invisible [Section titled “2026-08-19 — /context hands back prose, and a document is never invisible”](#2026-08-19--context-hands-back-prose-and-a-document-is-never-invisible) **A document your schema has no kind for is in the answer.** A Brain that declares `kinds` without a generic slot for plain prose had no name for a page nobody typed, and the reader answered that by dropping the page: `GET /documents` reported it `indexed`, `/search` ranked it first, and `/context` and `/query` returned nothing at all, with `warnings: []` — which these pages define as “this answer is complete”. A caller on that vocabulary could not tell a Brain holding their whole corpus from an empty one. Such a page is now in every read, under the kind `document`. Naming a page is a labelling decision; withholding it was a correctness one. **`text` is prose, not our projection of it.** `/context` returned the assembler’s internal JSON line per source — one `JSON.stringify` carrying the Brain’s opaque ids and storage paths, with the sentence that grounds the answer escaped inside a string — under a field documented as “paste this into your prompt”. `text` is now one readable block per source, blank-line separated: prose for a document, `field: value` lines for a record. Each block is the matching `citations[].quote` character for character, so what you show a user is exactly what the model was shown. Structure has not moved: ask for `format: "items"` and you get the typed pieces, unchanged. **The `processing` window stopped growing with the Brain.** The bounded window `/search` reads to count unindexed documents bounded its RESULT and not its work: it deduplicated every completed submission for the Brain and sorted the whole set before taking the newest hundred, which measured linear (1.4 ms at 5 documents, 3.3 ms at 1,000, extrapolating to \~200 ms on a 100k-document Brain, on the fail-closed path a `/search` cannot answer without). It is now an index range scan of the newest writes, the same cost whatever the Brain holds. ## 2026-08-19 — A read refuses what it cannot apply, and says what it is handing back [Section titled “2026-08-19 — A read refuses what it cannot apply, and says what it is handing back”](#2026-08-19--a-read-refuses-what-it-cannot-apply-and-says-what-it-is-handing-back) **An undeclared query parameter is a `400` naming it.** `/context` and `/query` already refused a retired `filter`, `about`, or `asOf` in their bodies; `GET /search`, the one read whose input rides the query string, accepted and dropped every parameter it did not declare. So `?q=renewal&filter={"source":"zendesk"}` was answered `200` with a whole-Brain ranking — the exact failure the refusal exists to prevent, made worse by being invisible. Every endpoint now refuses an unknown query parameter and names it alongside the ones it takes, so a client generated against an older preview shape fails at the parameter it should stop sending. **`/context` items say `record` when the piece is a record.** `format: "items"` labelled every item `document` and handed a typed record’s values back as YAML inside a markdown string, which meant the `record` branch of the union we published never ran for any input. Items now carry the kind the piece actually is: a `document` with its `text`, or a `record` with its `fields` as numbers and booleans, addressed by the id `PATCH /records/{id}` takes. The rendered `text` form carries the same typing, so an assembled prompt now names each source’s kind instead of burying it in frontmatter. **Two shapes nothing produced are gone.** The context item union published a `statement` variant no read emitted at that release — statement operations had not landed yet — and a `score` on the document variant, over a reader that selects deterministically and ranks nothing. Both return when a reader produces them. **`processing` on `/search` counts documents that are not in the passage index yet.** It was taken from active extraction jobs, so a document written with `extract: false` — or one in the window after extraction settles and before indexing finishes — was missing from a ranking while `warnings[]` was empty, which the contract defines as “this answer is complete”. `/search` now also counts accepted documents whose text is not chunked, over a bounded window of the Brain’s most recent writes. `/context` and `/query` still count extraction alone, because a page is in an accepted-state answer the moment it is wired, and warning that a whole answer was short is its own kind of lie. ## 2026-08-19 — Reads are live: `/context`, `/query`, and `/search` [Section titled “2026-08-19 — Reads are live: /context, /query, and /search”](#2026-08-19--reads-are-live-context-query-and-search) The three reads answer for real, all of them receipted and all of them fail-closed: if the receipt cannot be committed the response does not leave, and you get a retryable `503` instead of an answer nothing recorded. **`/context` is honest about its budget.** `maxTokens` used to accept anything up to 200,000 over an assembler that spent at most 8,192 and silently clamped to it — so a request for 50,000 was answered `200`, rendered 8,192, and reported `truncated: true`, which reads as “your budget ran out” when the budget was never applied. The published ceiling is now the one the assembler actually renders to, and asking for more is a `400` naming the limit. The default is unchanged at 2,048. Both numbers rise together when the assembler can spend more. **`/context` refuses `about`.** It promised to walk the relationships from a named subject; the assembler has no subject coordinate and no link traversal, so it would have returned a whole-Brain answer to a question that named one thing. Like `filter` and `asOf` before it, it is a `400` naming the field rather than a parameter that narrows nothing. **`/search` is `fulltext` or `hybrid`, and is not cursor-paged.** The preview published `documents` and `records` modes over an index that has no such split (a record IS a document there), and a `cursor`/`nextCursor` pair over a ranked top-N read that has no stable cursor to hand out — walking it would have served the same first page forever. Paging is now one `limit`, capped at 20, which is what the reader returns. Each result carries `rank` (1 is the best match) rather than a `score`: ranking fuses a keyword leg and a semantic leg into an ORDER, and the `0.87` the preview showed was a number nothing computes. **Every read carries `staged` and `warnings[]`.** An empty answer now says why it is empty — `staged` counts changes waiting for review, and `warnings[]` reports `processing` (accepted documents still being read), `truncated`, and `staged_pending`. `access_scoped` is published and not yet emitted by anything: no read on this surface is filtered by credential scope today. **Citations and search results name your document id.** `source` is the id you wrote the document under, so a citation round-trips into `GET /documents/{id}`; a page authored in the console has no such id and carries `slug` alone. Citations also carry the `recordedAt` of the source and the exact prose the answer rests on. **The console’s own passage search moved to a console-plane route.** It answers a different shape — citation tokens and per-brain attribution, off a session — and `/v1/brains/{handle}/search` is the public, receipted read. ## 2026-08-19 — Documents and record writes are live [Section titled “2026-08-19 — Documents and record writes are live”](#2026-08-19--documents-and-record-writes-are-live) Everything the quickstart’s third call does now answers for real. Write a document or a batch of them, read one back, list them, and ask what the Brain read out of one; merge into a record, replace one, delete one, or send up to twenty as a single atomic batch. Whether a write lands or waits for a human is the Brain’s `mode`, decided by its admission policy — the response’s `change.status` says which happened, and no request can ask for one. **A record id is a lowercase kebab identifier.** The preview accepted any non-whitespace token and offered `crm:account:4471` as an example, which no write could ever have landed on: a record is stored as a document whose address is exactly that string, and the store refuses punctuation. Most ids you already hold pass unchanged (`acme`, `user-4471`, a lowercase UUID); one that does not is lowercased and joined with `-` once, at your edge. The rule now says what the store enforces, so an id that parses is an id that writes. **A record write answers what it DID, not what the record holds.** The response carries the address, the kind, the version to send back as `If-Match`, which of your fields moved, and the values you submitted — and no others. It used to answer the record’s whole accepted state: every field value, including frontmatter no schema declares and no request mentioned. A key minted with `write` and not `read` could therefore page a Brain out one address at a time, with no [receipt](/docs/govern/receipts/) behind it, by sending an empty body to each one. Values now leave through `/context`, `/query`, and the record read, which are reads and are receipted. For the same reason `change.records[]` reports `set` and `cleared` — your submission, decided — in place of the `before` and `after` snapshots it used to carry. **A write has to state something.** A body with no `kind`, `label`, `fields`, or `links` is a `400`, and so is a batch entry carrying only an `id`. An empty write had exactly two uses: reading a record back through a write verb, and standing an empty record up at an id you typo’d. **A write that changes nothing writes nothing.** Re-sending a record you already sent answers `change.fields: []`, mints no version, and puts nothing in the review queue — which is the normal case for a nightly sync and worth counting. Such a change now carries **no `id`**: nothing was recorded, so there is nothing for `GET /changes` or the review operations to address, and the id it used to answer resolved to nothing anywhere. **`PUT` clears the fields your schema declares, and spares the ones it does not.** Undeclared frontmatter — a `source_url` from a markdown import, an `imported_by` a colleague wrote — is stored data no response on this surface shows you, so a `PUT` that omits it leaves it alone rather than deleting what it never let you see. Send it as `null` to clear it deliberately. Links are unaffected: `links` accepts only names your schema declares, so a link you omit from a `PUT` is still removed. **No idempotency key is derived from your request any more.** Two writes you meant separately are byte-identical to a retry — delete a record and resync it and the resync is the create again, to the byte — so a hashed key answered the FIRST request’s outcome to the second, reporting `applied` for a write it had dropped. `Idempotency-Key` is now accepted on `PATCH`, `PUT`, and `DELETE` as well as `POST /batch`, and absent one every request is its own. Updates stay naturally idempotent through the address; a create is the case worth a key. **Re-creating a record you deleted is refused today, and says so.** Deletion is soft — the record leaves the Brain and its history stays — and re-using the address is not yet supported underneath, so the write answers `409 conflict`. That is a change you can see, and it is the honest half of the one above: the same request used to answer `200 applied` while writing nothing at all, because the derived key replayed the outcome of the create you had already deleted. A `409` you can act on beats a `200` you cannot. Until the address can be re-used, write the record under a new id — and if you were relying on the old behaviour, you were relying on a write that never happened. **`record` carries no `version` when a write stages onto an address that holds nothing.** There is no accepted record there yet, so there is no version to name. The change is the answer in that case, and it says `staged`. **A link needs its target.** `links` writes a typed edge, and an edge names both endpoints — so a target the Brain does not hold yet is a `400` rather than an edge that silently never materializes. Send both records in one `POST /batch`, where records may reference each other, or write the target first. The preview said the target could arrive later; it could not. **A document write answers `queued`.** The bytes are stored and the document is addressable by your id immediately; a read reports `processing` until body search can reach it, then `indexed`. `chunks` is not reported by any read today — a chunk count is a traversal rather than a column, and `status` already answers the question it was standing in for. **Batch ceilings, stated as they actually work.** A record the Brain does not hold yet costs one canonical mutation; updating one costs a mutation per field that actually changes and per link added or removed. Twenty records is the published cap, and a batch that expands past the substrate’s hundred-mutation or 2 MiB ceiling is rejected whole, naming the ceiling it crossed. **Metadata keys beginning `nicia:` are reserved.** A document’s `title` and `occurredAt` are stored beside your own metadata under that prefix and lifted back out on every read, so `metadata` round-trips as exactly what you sent. ## 2026-08-19 — Brains and keys are live [Section titled “2026-08-19 — Brains and keys are live”](#2026-08-19--brains-and-keys-are-live) The nine operations these pages open with now answer for real: create or update a Brain on your own handle, read it, rename it or switch review on, delete it, list your organization’s Brains, and mint, list, rotate, and revoke the keys for one. They are in the published API reference because they are running, not because they are planned. **A management key goes in the same header as a Brain key.** Every example here sends `authorization: Bearer $NICIA_KEY`, and that is now literally true for both kinds — the prefix (`nsk_` or `nbk_`) decides how far the key reaches, not which header you put it in. **A new Brain starts in `open` mode.** That was the documented default before anything served it — the platform’s own create path provisioned a Brain to stage its writes — and it is now what you actually get. If you want writes to wait for a human, say `"mode": "reviewed"` on the `PUT` or switch it later with a `PATCH`. The default vocabulary is unchanged and stays [`nicia-base`](/docs/schemas/overview/#the-default-nicia-base): a `PUT` naming no `schema` gets it, and `nicia-core` is selected by name. **A deleted handle stays reserved.** `PUT` on a handle whose Brain was deleted is a `409`, not a new empty Brain — its receipts and audit history still resolve, and a re-running script should find out rather than quietly start over. Send `"revive": true` to mean it. **Removed before anyone could depend on them.** `asOf` on `/context` and `/query`: it described a read as of a moment against a reader with no temporal coordinate, so you would have been handed today’s answer wearing yesterday’s date — with a receipt to make it look verified. The temporal question has a real answer: [`GET /receipts/{id}`](/docs/govern/receipts/) pins the exact version of every source a read was shown, and `/receipts/{id}/now` says what has changed since. `overlapSeconds` on rotate: the overlap is a fixed 60-second window, and a knob that silently resolved to 60 would close a redeploy nine minutes early in silence. `prefix` on the Brain listing stays [Planned](/docs/scale/fleet/) rather than shipping as a filter that returns short pages and empty ones with a cursor. **A key can be `expired`.** `key.status` adds it, because `expiresAt` always implied it and reporting a lapsed key as `revoked` sends you looking for a revocation nobody performed. Key listings are also paged now — one key per end user is a documented pattern, and a listing that answers its first page as if it were the whole set is the one thing a credential inventory must not do. ## 2026-08-18 — the preview surface matches what will ship [Section titled “2026-08-18 — the preview surface matches what will ship”](#2026-08-18--the-preview-surface-matches-what-will-ship) Several things the preview described could not be built as written, one bound was wrong, and one capability granted more than these pages said it did. Rather than ship operations that answer something other than what the reference says, they are corrected in the contract now. **A key no longer chooses how its writes are admitted.** Minting a key used to take a `writes` setting of `direct` or `propose`, and a key record used to report one. Both are gone. Whether a write lands or waits is the Brain’s `mode`, and the Brain’s admission policy decides it for every write, whoever sent it — an outcome a caller can select is an outcome an agent can select. If you need a pipeline writing straight in while agents are reviewed, give it its own Brain. A mint still carrying `writes` is a `400` naming the field, for the same reason a retired query predicate is: a `201` that dropped the setting would promise immediate writes and deliver staged ones, and you would find out from a Review queue filling up rather than from the response. See [Review](/docs/govern/review/). **Documents cannot be deleted.** The preview described a delete that also retired every record extracted from the document and marked every receipt that cited it. That is correction work over the accepted state, not a delete, and nothing enforced it. Write the document again under the same id to supersede it; the old version stays resolvable, so past receipts still explain themselves. See [Documents](/docs/write/documents/). **Queries filter exactly, and say so when they cannot.** `where` now carries only the two predicates the reader evaluates: a scalar for equality, and `{ "has": … }` for one element of a string list, AND-ed, up to eight. Arrays as `or`, `{ "after": …, "before": … }` ranges, `orderBy`, and the `linked` relationship filter are removed. A request still carrying one is a `400` naming the field — never a correctly-shaped page that quietly answered a different question. See [Search and queries](/docs/read/search/). **`/context` and `/search` take no filter.** The preview described a read-time `filter` on both — a scalar matching exactly, an array as an `or`, `{ "after": …, "before": … }` bounding a date. Neither endpoint has a filter of any kind behind it: `/context` renders every source-backed document in the Brain inside your token budget, and `/search` ranks a passage index that knows nothing about your document metadata. Two of the three operators were never buildable as written — no declared field type is a date, so an `occurredAt` is a string whose only operator is equality, and nothing anywhere combines predicates with `or`. Exact filtering lives on `/query`, where a predicate is checked against a declared field. A `/context` request still carrying `filter` is a `400` naming it; a stripped one would have answered a whole-Brain question confidently and told you nothing. See [Context](/docs/read/context/) and [Search and queries](/docs/read/search/). **Document metadata was never an index.** These pages said every metadata key “becomes a filter at read time”. No read has ever indexed it. Metadata still takes any flat JSON object, nothing is declared and nothing is discarded, and every key comes back on the document — but slicing by one means declaring it as a field and querying it, which works retroactively precisely because nothing you already sent was thrown away. See [Documents](/docs/write/documents/). **A query page carries 100 records, not 500.** The reader slices every structured-query page to 100, so a larger `limit` described a page nothing would ever return. It is now the published bound, and a `limit` past it is a `400` rather than a page quietly cut down to size. Walk the rest with `nextCursor`. See [Search and queries](/docs/read/search/). **Context is assembled in a stable order, not a relevance ranking.** `/context` renders sources in a deterministic order keyed on each source’s id and stops when the budget is spent — which is what makes two identical calls return the same text and a receipt worth holding. These pages called that order “relevance”, which mattered because it changes what truncation means: `truncated` tells you the budget ran out, not that what was dropped mattered least. The documented default budget was wrong too. Items no longer carry a `score`, which nothing ever computed. See [Context](/docs/read/context/). **A batch carries 20 records, not 500.** A record is not one unit of work: each field and each link becomes its own entry in the canonical change Nicia records, against a ceiling of 100 entries and 2 MiB per batch. A 500-record batch could never have been applied. Twenty is the ceiling for a four-field record; a wider record still crosses it, and a batch that does is rejected whole. See [Records](/docs/write/records/). **A change reports two more states.** `change.status` adds `partially_accepted` — a reviewer accepted some records and not others — and `superseded`, for a staged change whose target moved on before anyone settled it. Both were already reachable; the wire had nowhere to put them, so they had to be reported as `staged`, which meant a queue entry that was not in the queue. Branch on `status` with a default case. See [Review](/docs/govern/review/). **A receipt can report a source under legal hold.** Asking what changed since a receipt can now answer `held` alongside `superseded` and `deleted`. A hold makes a source temporarily unservable, and the previous wire had to report that as unchanged — the one answer a hold exists to prevent. See [Receipts](/docs/govern/receipts/). **A handle must survive a path segment.** Handles now reject `/` and whitespace. Everything else stays permissive: a handle is usually an id you already hold, and `tenant:acme`, `acme`, and a UUID are all fine. A Brain provisioned before this with a slash or a space in its handle is still reachable by its opaque id in the same slot. **`admin` no longer carries export or webhooks — and now really cannot read.** These pages promised that `admin` grants no access to knowledge while also listing export under it. Both could not be true: a bulk export is the Brain’s entire contents, every revision and every source, and a change webhook is the same contents delivered continuously to a URL the key’s holder picks. `admin` now grants exactly one thing on the live surface — `PATCH /v1/brains/{handle}`, which switches review on and off — so the sentence you can hand a key to a schema or ops contractor on is the sentence the gate enforces. Reading the schema is not part of it and never was: `GET /schema` and `GET /schema/candidates` need `read`, like every other read. Schema evolution, export, and webhook subscription are off the Brain-key surface until they arrive with a capability named for what they move; until then they are reached with a credential issued from the management plane. See [Authentication](/docs/start/authentication/). ## 2026-08-18 — the surface enters the reference [Section titled “2026-08-18 — the surface enters the reference”](#2026-08-18--the-surface-enters-the-reference) **Added — the Brain API is published in the [API reference](/docs/reference/).** Part of it is a *specification* rather than a description: documents, records, reads, and govern have no route behind them yet. Every such operation carries `x-nicia-preview: true` and says so in its own description, in one of two forms. * Most preview paths have no route at all and answer `404` today. * `GET /v1/brains/{handle}/search` and `GET /v1/brains/{handle}/changes` share a path and method with an older, differently-shaped implementation that is live. A request to one of those reaches the older handler instead: it answers in its own shape and ignores the fields this surface defines. Do not read its `200` as this API working. Generate nothing against a preview operation expecting a live endpoint. When a route lands, its marker is removed and the change is dated here. **Removed (breaking) — the `kind/slug` record alias.** A record is addressed by the bare id you chose for it, one path segment, and that is the only form on the wire. The two-segment typed form was documented but no schema accepted it, so every request that followed the docs failed. It may return later as an additive route. **Policy — the beta label.** The preview banner on these pages, the banner on the reference, and every preview operation carry one sentence: this surface may change before GA, and breaking changes are dated in this changelog. That is the whole promise. A breaking change without an entry here is a bug — tell us. # Events and webhooks > One ordered stream of what the Brain accepted and how its changes moved — read it by cursor, or have it pushed to your URL. Proposal lifecycle events are `proposal.opened`, `proposal.revised`, `proposal.applied`, and `proposal.closed`. They carry Proposal and revision addresses, and flow through the same cursor stream and webhook delivery as other Brain events. A branch-scoped key cannot read the main event stream. Preview — events and webhooks may change without notice The event stream and its webhooks are preview: they work, but their shape may change or be removed without notice. See [Stable and preview](/docs/api/stability/). Every Brain keeps one ordered stream of what happened to it: records accepted and deleted, statements asserted and retracted, documents served and retired, schema changes, changes staged, decided and published, holds, source snapshots planned and applied, and declared dates reached. Read it with a cursor, or have it pushed to your own URL. Use it to keep a downstream copy in sync, to rebuild an answer when the facts under it move, or to start your own agent when something changes. Nicia tells you **that** something changed and **where**. Your agents decide what to do about it. Events carry addresses and versions — a record’s id and kind, the names of the fields a change set, a document’s id, a change id, a version number — and never a value or any text. Read content the way you always do, through [`/context`](/docs/read/context/), [`/query`](/docs/read/search/) and `GET /records/{id}`, so every answer still has a [receipt](/docs/govern/receipts/). `GET /changes` stays what it is: the [review queue](/docs/govern/review/). The stream is the feed. Reads are not on the stream. It carries what the Brain accepted and how its changes moved; what the Brain served, and to whom, is in its receipts — [list them](/docs/govern/receipts/#listing-receipts), or ask [who was served](/docs/govern/receipts/#who-was-served) the address an event names. ## Read the stream [Section titled “Read the stream”](#read-the-stream) Any key with `read` can read the stream: Each event carries `branchId` (`default` for main). Without `?branch`, a main-scoped key follows main: what main accepted, plus every Proposal event, whose `branchId` names the branch it proposes from. Nothing written on a branch reaches main’s stream until a Proposal publishes it. Add `?branch=` to follow one branch. A branch-scoped key sees only its own branch events when it omits the parameter; it cannot select main. Webhooks deliver main’s stream. ```bash curl "$API/v1/brains/tenant:acme/events?types=record.accepted,record.deleted&limit=100" \ -H "authorization: Bearer $NICIA_KEY" ``` ```json { "data": { "events": [ { "cursor": "e1.41.1.gen_7Hq2", "type": "record.accepted", "branchId": "default", "occurredAt": "2026-09-23T10:14:02.118Z", "data": { "record": { "id": "bob", "kind": "person" }, "operation": "updated", "fields": ["role"], "links": [], "change": "chg_44a1e0", "stateVersion": "native:r57" } }, { "cursor": "e1.43.1.gen_7Hq2", "type": "record.deleted", "branchId": "default", "occurredAt": "2026-09-23T10:15:40.502Z", "data": { "record": { "id": "acme-old", "kind": "company" }, "change": "chg_51c2d9" } } ], "cursor": "e1.44.0.gen_7Hq2", "hasMore": false } } ``` * **`after`** — the `cursor` of a page, or of any single event. Omit it to read from the beginning of the Brain’s history. * **`types`** — a comma-separated list of the event types below. An unknown name is a `400`, not a filter that silently matches nothing. * **`limit`** — up to 500 events per page; the default is 100. Keep the page’s `cursor` and pass it back as `after`, **also when `events` is empty**: the cursor moves past everything the page looked at, including the events your `types` left out, so the next poll starts where this one stopped. `hasMore: false` means you have reached the end of what the Brain has committed. ## No gaps, no reordering [Section titled “No gaps, no reordering”](#no-gaps-no-reordering) The stream is ordered by commit, and it has no gaps: an event is never returned while an event that committed before it is still being written. A reader that walks the stream page by page, resuming from each page’s `cursor`, sees every event exactly once and in order — however many writers are working on the Brain at the time. Cursors are opaque; do not build or parse them. A cursor from before a Brain was deleted and recreated under the same handle is refused with `409`, because it points into a different Brain’s history. ## Event types [Section titled “Event types”](#event-types) Every event has a `cursor`, a `type`, an `occurredAt`, and a `data` object. `change` in `data`, where present, is the id `GET /changes` and the change lifecycle use. | Type | `data` | | ---------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------ | | `record.accepted` | `record` (`id`, `kind`), `operation` (`created` or `updated`), `fields` and `links` it touched, `retypedFrom` when it changed kind, `change`, `stateVersion` | | `record.deleted` | `record`, `change`, `stateVersion` | | `statement.asserted` | `statement` (`id`), `predicate`, `subject` (`id`, `kind`), `change`, `stateVersion` | | `statement.retracted` | `statement`, `change`, `stateVersion` | | `document.served` | `document` (`id`, plus `source` for a document of a [source](/docs/write/sources/)), `version` — the number a receipt pins — and `change` | | `document.retired` | `document`, `change` | | `schema.evolved` | `schemaVersion` — the `declaredVersion` `GET /schema` now returns — and `previousSchemaVersion` | | `change.staged` | `change`, and `supersedes` when a decision split an earlier change | | `change.decided` | `change`, `decision` (`pending`, `approved`, `rejected`, `superseded`), `application` (`not_applied`, `scheduled`, `blocked`, `applied`, `cancelled`) | | `change.published` | `change`, `stateVersion` | | `availability.changed` | `from`, `to` (`active`, `held`, `deleted`) | | `snapshot.planned` | `source`, `snapshot`, `planRevision` | | `snapshot.applied` | `source`, `snapshot`, `planRevision`, and `reviewChange` when the apply staged a change | | `date.reached` | `record` (`id`, `kind`), `field`, `offset` — the `notifyBefore` duration crossed, `P0D` for the date itself — and `dueAt`, the instant it was due | `stateVersion` is the Brain state a change produced — the same value a receipt’s `snapshot.stateVersion` carries — when the Brain recorded one. A reviewed change reads as `change.staged`, then `change.decided` when someone approves or rejects it, then — if it is published — the `record.*`, `statement.*` and `document.*` events it applied, followed by `change.published`. ### Date reached [Section titled “Date reached”](#date-reached) A `date` or `datetime` field that declares `notifyBefore` ([Schemas](/docs/schemas/overview/#dates-and-instants)) puts the passage of time on the stream: when a record’s value minus an offset is reached, the Brain emits one `date.reached`. A `date` is reached at the start of its day in UTC. ```json { "data": { "events": [ { "cursor": "e1.88.0.gen_7Hq2", "type": "date.reached", "branchId": "default", "occurredAt": "2026-11-01T00:00:41.207Z", "data": { "record": { "id": "site-042", "kind": "site" }, "field": "contract_end", "offset": "P30D", "dueAt": "2026-11-01T00:00:00.000Z" } } ], "cursor": "e1.89.0.gen_7Hq2", "hasMore": false } } ``` * **Once.** Each record, field, value, and offset is emitted exactly once, however often the Brain checks. * **A changed value reschedules.** Moving `contract_end` drops the old value’s pending crossings and schedules the new value’s. Changing it back does not repeat a crossing already emitted for that value. * **A deleted record cancels** its pending crossings. * **Only crossings the Brain watched.** A crossing that is already in the past when the value is written — a record imported with an old contract date — is not emitted. * **About a minute.** The Brain checks once a minute, so an event arrives within about a minute of `dueAt`, then through webhooks on their own schedule. `dueAt` is derived from the value, so it tells you the date; read the record itself, receipted, before acting on it. ## Webhooks [Section titled “Webhooks”](#webhooks) A key with `admin` and `read` can have the stream pushed to a URL — `admin` because a webhook is configuration, `read` because it carries the stream out. Listing and deleting webhooks need only `admin`. Choose the event types you want: ```bash curl -X POST "$API/v1/brains/tenant:acme/webhooks" \ -H "authorization: Bearer $NICIA_KEY" \ -H "content-type: application/json" \ -d '{ "url": "https://hooks.example.com/nicia", "types": ["record.accepted", "record.deleted"] }' ``` ```json { "url": "https://hooks.example.com/nicia", "types": ["record.accepted", "record.deleted"] } ``` ```json { "data": { "webhook": { "id": "01a0cef2-9115-7760-95cd-35c1a0abc32d", "url": "https://hooks.example.com/nicia", "types": ["record.accepted", "record.deleted"], "createdAt": "2026-09-23T10:20:11.004Z", "createdByKeyId": "key_7f3a1c", "pending": false }, "secret": "6f1c0e8a-3d52-4b7e-9f0a-2c8d4e6b1a90" } } ``` The `secret` is shown once. Delivery starts with the next event the Brain commits; to hand over from a reader without a gap, pass the last `cursor` you read as `after` when you create the webhook. The URL must be HTTPS on a public host. Private, loopback, link-local and metadata addresses are refused when you create the webhook, and every delivery checks the address again, so a host that later resolves somewhere private still gets nothing. ### What you receive [Section titled “What you receive”](#what-you-receive) Each delivery is a `POST` with one event — exactly as `GET /events` lists it — and the cursor of the event this webhook delivered before it: ```json { "webhook": "01a0cef2-9115-7760-95cd-35c1a0abc32d", "previousCursor": "e1.41.1.gen_7Hq2", "event": { "cursor": "e1.43.1.gen_7Hq2", "type": "record.deleted", "occurredAt": "2026-09-23T10:15:40.502Z", "data": { "record": { "id": "acme-old", "kind": "company" }, "change": "chg_51c2d9" } } } ``` Every delivery is signed. `X-Nicia-Signature` is `sha256=` followed by the hex HMAC-SHA256 of the raw body with your secret; compare it before you trust the body: ```ts import { createHmac, timingSafeEqual } from "node:crypto"; function verified(rawBody: string, header: string, secret: string): boolean { const expected = `sha256=${createHmac("sha256", secret).update(rawBody).digest("hex")}`; return header.length === expected.length && timingSafeEqual(Buffer.from(header), Buffer.from(expected)); } ``` Answer with any `2xx` to acknowledge it. ### Order, retries, and catching up [Section titled “Order, retries, and catching up”](#order-retries-and-catching-up) Deliveries are **in order and at least once**. The next event is not sent until the one before it has been acknowledged, and an event can arrive twice, so key your handling on its `cursor`. Delivery follows the committed stream on a schedule; expect an event within about a minute of its commit. A delivery that fails is retried — three attempts in all, over the next couple of minutes. One that still fails is skipped, so a receiver that was down does not hold every later event back forever, and the webhook’s `lastFailure` says so. The next delivery tells you: its `previousCursor` is not the last cursor you processed. When that happens, read what you missed before handling it: ```bash curl "$API/v1/brains/tenant:acme/events?after=$LAST_PROCESSED_CURSOR&types=record.accepted,record.deleted" \ -H "authorization: Bearer $NICIA_KEY" ``` The stream is the record; the webhook is how you hear about it. ### Manage webhooks [Section titled “Manage webhooks”](#manage-webhooks) `GET /v1/brains/{handle}/webhooks` lists the Brain’s webhooks with how far each has delivered — `lastCursor`, whether an event is `pending`, and the most recent `lastFailure` — and never their secrets. `DELETE /v1/brains/{handle}/webhooks/{webhookId}` stops one at once; an event being retried is not sent again. A Brain holds at most 25 webhooks. Deleting a Brain that has been written to reaches its webhooks as `availability.changed` to `deleted`; after that the Brain’s events can no longer be read. ## From an agent [Section titled “From an agent”](#from-an-agent) The Brain’s MCP server has the same read as the tool `brain_events`, for a key with `read`. It takes `after`, `types` (a list), and `limit`, and returns the same page. ## Next [Section titled “Next”](#next) * **[Receipts](/docs/govern/receipts/)** — what a read was served, for reading the content an event points at, and who was served the version an event replaced. * **[Review](/docs/govern/review/)** — the queue behind `change.staged`. # Receipts > Every read records exactly what it was shown, so you can defend an answer months later. Every read — `/context`, `/search`, `/query` — returns a receipt id: The receipt snapshot includes `branchId`. For a branch read, this is the branch’s immutable identity and the head served at read time. Pass `?branch=` to receipt listing, inspection, `now`, pinned-document, and who-was-served reads when using a main-scoped key. A branch-scoped key is limited to its own receipts, even if it guesses a receipt id from main. One Brain · chain of custodyIllustrative lifecycle 01**Sources** **Records **Documents **Agent writes 02**Review** Proposed**Human or policy decision**Accept · edit · reject 03**Context** Accepted state**Cited answer**\[1] source passage 04**Receipt** Verified`rcp_019f`What the read consumed ```json { "data": { "text": "Acme renewed at $120k for 24 months on 2026-03-14.", "citations": [ { "source": "hubspot:deal:412", "slug": "brain-evidence-0dd3e2449851d9ba41924c42bd7a61375a8b78050bfe2ca7192eb918d626236a", "quote": "Acme renewed at $120k for 24 months." } ], "staged": 0, "warnings": [], "receipt": "rcp_019ffefe", "truncated": false } } ``` Log it next to whatever your agent said. It is the difference between “our model probably had the renewal note” and “here is the exact version of the renewal note it was given, at 11:02 on 14 August.” Every serve is its own receipt, with its own time and consumer. The same request made twice, against the same state, returns two receipts, so a listing by time window and [who was served](#who-was-served) count each read. Retrying one request after a dropped connection is a new read. ## Fetching one [Section titled “Fetching one”](#fetching-one) A receipt records which key made the read — `key` — and that binding is what makes it evidence rather than a log line anyone could have written. It never changes, whoever reads the receipt back. Fetch a receipt with the **Brain key that made the read**, or with a key that holds `review` or `admin` — your management key does. [Who was served](#who-was-served) needs `review` and hands back receipt ids, so a reviewer can open the receipts it lists; an operator auditing their own Brain is the reader receipts are for. Any other key gets a `404`, the same answer a wrong id gets. Receipts outlive their Brain: after `DELETE /v1/brains/{handle}` the management key still fetches the receipt (a Brain key answers `404` once its Brain is deleted, as it does for every read). [What changed since](#what-changed-since) and [the text it pinned](#the-text-a-receipt-pinned) need a live Brain, and answer `404` once it is deleted. ```bash curl "$API/v1/brains/tenant:acme/receipts/rcp_019ffefe" \ -H "authorization: Bearer $NICIA_BRAIN_KEY" ``` ```json { "data": { "receipt": { "id": "rcp_019ffefe", "kind": "context", "consumerId": "user:4471", "key": { "id": "key_7f3a1c", "name": "Acme web app" }, "snapshot": { "brainId": "brain_3a91c7", "generation": "6f1e4d6b-9f0c-4a21-8f3e-1f1a2b3c4d5e", "stateVersion": "sv_019ffefe", "schemaVersion": "nicia-base", "policyVersion": "pol_nicia_open" }, "committedAt": "2026-08-14T11:02:44Z", "items": [ { "source": "hubspot:deal:412", "version": 2, "status": "active" } ] } } } ``` `items` pins the exact **version** of every source. A document of a named [source](/docs/write/sources/) also carries `document: { source, id }`. If the document has been rewritten twice since, or retired, the receipt still names the version that was served, and the key that made the read can still fetch [the text that version held](#the-text-a-receipt-pinned). `kind` says what the read was: `context`, `search`, `query`, `plan` (a [snapshot](/docs/write/snapshots/) plan page), or `document` (a read of the text an earlier receipt pinned). ## What changed since [Section titled “What changed since”](#what-changed-since) Same scoping: the key that made the read, or a key with `review` or `admin`. ```bash curl "$API/v1/brains/tenant:acme/receipts/rcp_019ffefe/now" \ -H "authorization: Bearer $NICIA_BRAIN_KEY" ``` ```json { "data": { "changed": [ { "source": "hubspot:deal:412", "shownVersion": 2, "currentVersion": 4, "status": "superseded" } ], "unchanged": 3, "deleted": 0, "evidence": [], "answers": [] } } ``` This answers the question that actually comes up in an incident: *the agent told a customer $120k — was that wrong at the time, or has it changed since?* Here it was right when it was said. `changed[]` names versions rather than text: `currentVersion` is the version the Brain serves now, and a document that was retired reports `deleted`. The text the receipt pinned is [its own read](#the-text-a-receipt-pinned). No read returns a document’s current text on its own — `GET /documents/{id}` reports its status and version, and `/context` and `/search` serve its passages. Re-sending a document under the same id counts — it replaces the document, and every receipt that cited the old version reports `superseded` from that moment. A cited source can also report `held`: under a legal hold, every item a receipt cited reports `held` rather than `superseded` or `unchanged`, so a hold is never mistaken for “nothing changed.” ### An answer whose grounds moved since [Section titled “An answer whose grounds moved since”](#an-answer-whose-grounds-moved-since) A version number can stay the same while what it rests on moves. For every [standing answer](/docs/read/context/#a-standing-answer-whose-grounds-moved) a receipt served, `answers[]` compares the validity it was served with (as the receipt recorded it) with its validity now. `since` is one of: * `grounds_moved`: something the served version rests on moved after the read. This includes an unchanged version whose claim changed, whose quoted source text changed, whose source was retired, or that gained new evidence. `movedSince` names each reason. * `replaced`: a new version was accepted. * `unchanged`: nothing monitored moved after the read. An answer served while already `needs_review` stays `unchanged` if nothing further moved. * `unknown`: the receipt recorded no validity (it predates validity), or the check cannot tell now. It reports what changed after the read, never that the answer was wrong when it was served. ```json { "data": { "changed": [], "unchanged": 2, "deleted": 0, "evidence": [], "answers": [ { "id": "refund-answer", "shownVersion": 1, "currentVersion": 1, "since": "grounds_moved", "servedValidity": { "state": "current", "reasons": [], "affirmed": { "position": 9, "at": "2026-09-28T09:00:00.000Z" }, "monitored": { "claims": ["refund-window"], "sources": ["refund-policy"] }, "observedThrough": { "position": 9, "at": "2026-09-28T09:00:00.000Z" } }, "validity": { "state": "needs_review", "reasons": [ { "kind": "input_changed", "recordId": "refund-window", "changed": { "position": 14, "at": "2026-09-28T10:30:00.000Z" } } ], "affirmed": { "position": 9, "at": "2026-09-28T09:00:00.000Z" }, "monitored": { "claims": ["refund-window"], "sources": ["refund-policy"] }, "observedThrough": { "position": 15, "at": "2026-09-28T11:00:00.000Z" } }, "movedSince": [ { "kind": "input_changed", "recordId": "refund-window", "changed": { "position": 14, "at": "2026-09-28T10:30:00.000Z" } } ] } ] } } ``` ## The text a receipt pinned [Section titled “The text a receipt pinned”](#the-text-a-receipt-pinned) Preview — this read may change without notice Reading the text a receipt pinned is a preview operation: it works, but its shape may change or be removed without notice. A receipt names versions; this read returns what they held. It answers the full text of every document version the receipt pinned, in the receipt’s own order, even when the document has since been rewritten or retired — the passages the answer quoted came from that text. ```bash curl "$API/v1/brains/tenant:acme/receipts/rcp_019ffefe/documents" \ -H "authorization: Bearer $NICIA_BRAIN_KEY" ``` ```json { "data": { "documents": [ { "source": "hubspot:deal:412", "version": 2, "contentHash": "sha256:4be1f07d5b8a3c1e9f2a6d0b7c4e8a13f5d9b2c6e0a7f4d1b8c3e6a9f2d5b0c7", "text": "Acme renewed at $120k for 24 months. Signed 2026-03-14." } ], "receipt": "rcp_019fff2a" } } ``` * **Only through the receipt, only for its key.** The key that made the read, or a key with `review` or `admin`, gets the text; any other key gets `404`. A version no read was ever served — a [snapshot’s](/docs/write/snapshots/) document that was never applied — is pinned by no receipt, so there is nothing to read it through. * **Exact or nothing.** `contentHash` is `sha256:` and the digest of `text`. Stored content that no longer matches the version it was written as fails the read with an error; it is never served in the pinned version’s place. * **Documents only.** Records a receipt pinned are not included: a record’s earlier values are not served, and [the record read](/docs/write/records/#reading-a-record-back) returns its current one. * **A read of its own.** Handing text back is content leaving the Brain, so each page commits a receipt of kind `document` over the versions it returned and answers its id as `receipt`. It takes a `consumerId` like any other read. * **Paged.** Up to 25 documents per page, 10 by default (`limit`); walk the rest with `cursor=`. ## Correlating by consumer [Section titled “Correlating by consumer”](#correlating-by-consumer) Pass a `consumerId` on any read — `/context`, `/search`, or `/query` — and it is recorded on the receipt: ```bash curl -X POST "$API/v1/brains/tenant:acme/context" \ -H "authorization: Bearer $NICIA_KEY" \ -H "content-type: application/json" \ -d '{ "prompt": "…", "consumerId": "user:4471" }' ``` `consumerId` is a label you choose — `user:4471`, `job:nightly-scoring`, `session:abc` — 1 to 512 characters; empty or longer is a `400` naming the field. It is correlation only. It never restricts what a call can reach, so scoping still comes from the key. ## Listing receipts [Section titled “Listing receipts”](#listing-receipts) Preview — listing receipts may change without notice Listing receipts, and the who-was-served reads below, are preview operations: they work, but their shape may change or be removed without notice. List receipts newest first, without their items. Filter by `consumerId`, `kind`, `key`, and a committed-time window — `since` inclusive, `until` exclusive, so adjacent windows never count a receipt twice. A key lists the receipts it committed; a key with `admin` lists every key’s, or one key’s with `key`. `review` opens a receipt it is handed the id of, but does not list other keys’ receipts. That is how a batch eval walks exactly the reads one run made: ```bash curl "$API/v1/brains/tenant:acme/receipts?consumerId=job:nightly-scoring&since=2026-09-01T00:00:00Z&until=2026-09-02T00:00:00Z" \ -H "authorization: Bearer $NICIA_BRAIN_KEY" ``` ```json { "data": { "receipts": [ { "id": "rcp_019ffefe", "kind": "context", "consumerId": "job:nightly-scoring", "key": { "id": "key_7f3a1c", "name": "Scoring job" }, "snapshot": { "brainId": "brain_3a91c7", "generation": "6f1e4d6b-9f0c-4a21-8f3e-1f1a2b3c4d5e", "stateVersion": "sv_019ffefe", "schemaVersion": "nicia-base", "policyVersion": "pol_nicia_open" }, "itemCount": 3, "committedAt": "2026-09-01T02:14:09Z" } ], "nextCursor": "rcp_019ffefe" } } ``` A receipt is identified by what the read served, not when it happened. A read that serves exactly what an earlier read served, under the same request, returns the earlier receipt and its original `committedAt` rather than a new one, so a `since` window and the who-was-served reads below list each distinct serve once. A job that reads unchanged state every day appears in these listings on the day it first read it. Count reads from your own `consumerId` log, not from receipt counts. Each receipt’s items are `GET /receipts/{id}`, and the text its documents held is `GET /receipts/{id}/documents` — so an eval can pin every outcome to the exact context it was given, even after the documents changed. Up to 250 receipts per page; walk the rest with `cursor=`. A listing carries no content: nothing a read handed out comes back through it. ## Who was served [Section titled “Who was served”](#who-was-served) Preview — who was served may change without notice These reads are preview operations: they work, but their shape may change or be removed without notice. After a correction, the question is who was told the old version. Ask the record, with a key that holds `review`: ```bash curl "$API/v1/brains/tenant:acme/records/acme-co/reads?stale=true" \ -H "authorization: Bearer $NICIA_BRAIN_KEY" ``` ```json { "data": { "reads": [ { "receipt": "rcp_019ffefe", "kind": "query", "consumerId": "user:4471", "key": { "id": "key_7f3a1c", "name": "Support bot" }, "committedAt": "2026-08-14T11:02:44Z", "servedVersion": 1, "currentVersion": 2, "status": "superseded" } ] } } ``` Every receipted read that served the record, from any key, newest first, with the version it served, the version the Brain serves now, and `status`: `current`, `superseded`, `retracted` (a retracted statement), `deleted` (a deleted record or a retired document, with no `currentVersion`), or `held` under a legal hold. A record’s version numbers restart when it is deleted and created again at the same address, so a read of the deleted record is `deleted` even though the address serves again: `currentVersion` is absent and `recreatedVersion` names the new record’s version, which is not a later version of the one served. `GET /receipts/{id}/now` reports the same item as `deleted` with `recreatedVersion`. `stale=true` keeps only the reads whose version is no longer current — a correction’s blast radius. The same read exists for a document, `GET /documents/{id}/reads`, and for a document of a named [source](/docs/write/sources/), `GET /sources/{name}/documents/{id}/reads`. Reading the text a key reads back through a receipt is a read too, of kind `document`, so it appears here beside the read that pinned it. This is metadata: receipt ids, keys, consumers and version numbers, never field values or text. It needs `review` because it describes every key’s reads; the content stays behind the receipt reads above. ## Corrections [Section titled “Corrections”](#corrections) When something turns out to have been wrong, write the correction — same document `id`, or a new warranted fact — rather than quietly forgetting the old number. The next `/context` is current. The receipt from the old read is stale: `GET /receipts/{receiptId}/now` reports `superseded`, and [`?stale=true` on the record’s or document’s reads](#who-was-served) lists every receipt that was served the old version, with its consumer and key — who needs to hear about it. To learn that a correction landed without polling each address, follow the [event stream](/docs/govern/events/): `record.accepted`, `record.deleted`, `statement.retracted`, `document.served` and `document.retired` name the address that changed, which is the address to ask for its reads. Reads themselves are not events; this page is where they are. ## The context check [Section titled “The context check”](#the-context-check) A receipt records what was served; the context check grades it. In the product, open a served context and run the check: it reports where the sources behind that answer contradict each other (each contradiction finding is judged by a live model before it is shown), where the same wording was sent twice, and where the payload spent budget on words that carried no information. The check is a report, run after the fact against a payload you already served — it is not a gate in the read path, and nothing blocks or rewrites an answer. It runs on the knowledge you have now, from the receipted reads you have already made: reads served over the API, over MCP, or under a Brain key. Reading a Brain by clicking around the console is not one of them and mints no receipt, so a Brain nobody has queried through a credential yet has nothing here to grade. Once one has, there is nothing to migrate before finding out what your agents are actually reading. ## Retention [Section titled “Retention”](#retention) Receipts are retained indefinitely — nothing deletes one. They survive a document’s retirement (a document is never deleted) and Brain deletion, and outlive the Brain they are evidence about — an answer you gave a customer stays explainable after they churn. `GET /receipts/{id}` keeps answering for the management key after the Brain is deleted. The [Brain export](/docs/team/tend-it/) carries them as `receipts.json`: every receipt the exporting key can list, with its snapshot, request and render hashes, and the version of every source it pinned — but not the text a read was handed, which stays behind the receipt reads. ## Next [Section titled “Next”](#next) * **[Review](/docs/govern/review/)** — deciding what lands in the first place. * **[Context](/docs/read/context/)** — where receipts come from. * **[Events and webhooks](/docs/govern/events/)** — what changed, to find the reads a change made stale. # Review > Four admission levels let Brain owners decide which agent writes land immediately and which wait for review. A Brain has one of four admission levels. The level is an owner decision: the Brain’s policy decides each write, never the caller. Human writes land, with one exception: a [source snapshot](/docs/write/snapshots/) that would change a value a person or another source supports waits in Review below L4, whoever sent it. The ladder governs agent writes. A write made with a management key (`nsk_`) is a person’s: it acts as the person who minted the key, and is recorded against the key’s id so it stays distinguishable from that person’s console writes. | Level | Name | Agent writes that land automatically | | ----- | ------------------------------------ | ------------------------------------------------------ | | L1 | Human approval only | None; every agent write stages for review | | L2 | Auto-approve trivial | No-op and formatting-only edits | | L3 | Auto-approve new and non-conflicting | Writes admitted by the consequence rules (the default) | | L4 | Auto-approve all | Everything, including destructive deletes | The API keeps the historical mode literals for compatibility: `reviewed` is L1 and `open` is L3. The new literals are `trivial` (L2) and `unrestricted` (L4). Legacy `propose` write-policy values resolve to L1 and `direct` values resolve to L3; responses use the level’s canonical literal. `mode` is a property of [the Brain](#the-brain-decides-not-the-caller), never of the caller. One Brain · chain of custodyIllustrative lifecycle 01**Sources** **Records **Documents **Agent writes 02**Review** Proposed**Human or policy decision**Accept · edit · reject 03**Context** Accepted state**Cited answer**\[1] source passage 04**Receipt** Verified`rcp_019f`What the read consumed ```bash curl -X PATCH "$API/v1/brains/tenant:acme" \ -H "authorization: Bearer $NICIA_KEY" \ -H "content-type: application/json" \ -d '{ "mode": "reviewed" }' ``` Keyed API-created Brains default to L3 (`open`), so governed routine writes do not require a person on every change. Console-created Brains default to L1 (`reviewed`), so new knowledge appears in Review while trust is being built. Choose a looser or tighter level as the Brain’s role changes. ## What changes [Section titled “What changes”](#what-changes) Nothing about how you write. The same call returns a different `status`: ```json { "data": { "records": [{ "id": "acme", "kind": "company" }], "change": { "id": "chg_44a1e0", "status": "staged", "createdAt": "2026-08-14T10:59:41Z" } } } ``` Branch on `change.status`, always: | Status | Meaning | | ---------- | ----------------------------------------------------------------- | | `applied` | It landed. Readable now. | | `staged` | Waiting for a decision. Not readable yet. | | `rejected` | The Brain declined it. The request was fine; the content was not. | A rejection is a `200`. The request was well-formed and the Brain made a decision — that is not an HTTP error. A write only ever returns one of those three. A change you read back from `GET /changes` can also carry `superseded` (a selection, below, replaced it with a successor, or its target moved on before it was reviewed). The `?status=` filter on `/changes` still only selects the three admission outcomes above. Reads tell you the same thing from the other side: every `/context` response carries `staged`, so an empty answer distinguishes “nothing known” from “waiting on you.” ## Settling the queue [Section titled “Settling the queue”](#settling-the-queue) ```bash curl "$API/v1/brains/tenant:acme/changes?status=staged" \ -H "authorization: Bearer $NICIA_KEY" ``` ```json { "data": { "changes": [ { "id": "chg_44a1e0", "status": "staged", "author": { "kind": "key", "id": "key_7f3a1c", "name": "support-agent" }, "records": [ { "id": "bob-smith", "kind": "person", "before": { "role": "Head of Platform" }, "after": { "role": "VP Platform" }, "evidence": [ { "source": "zendesk:ticket:8821", "quote": "Bob Smith, now VP Platform, confirmed…" } ] } ], "createdAt": "2026-08-14T10:55:12Z" } ], "nextCursor": "eyJrIjoi…" } } ``` Every staged change carries the diff and the passage it came from, so a reviewer is deciding about evidence rather than about a model’s assertion. A [source snapshot’s](/docs/write/snapshots/) change also lists the `documents` it serves or retires (`{ source, id, action }`), apart from its `records`. Deciding a change is two steps on the lifecycle verbs. **Approve** binds the exact candidate you reviewed; **publish** applies it. Both need the `review` capability. Approve’s body may be omitted, except for a change that carries a standing answer: see [below](#standing-answers-and-the-regression-check). ```bash curl -X POST "$API/v1/brains/tenant:acme/changes/chg_44a1e0/lifecycle/approve" \ -H "authorization: Bearer $NICIA_KEY" curl -X POST "$API/v1/brains/tenant:acme/changes/chg_44a1e0/lifecycle/publish" \ -H "authorization: Bearer $NICIA_KEY" \ -H "content-type: application/json" \ -H "idempotency-key: publish-chg_44a1e0" \ -d '{}' ``` Publishing again under the same `Idempotency-Key` returns the committed outcome instead of applying twice. ### Standing answers and the regression check [Section titled “Standing answers and the regression check”](#standing-answers-and-the-regression-check) A change that proposes a standing answer is approved normally only when its regression check passed for that exact revision. Its outcome is on the change (`evaluationState`), and an edit after the check makes a pass stale. Any other finished outcome needs a recorded reason, which is kept with the reviewer: ```bash curl -X POST "$API/v1/brains/tenant:acme/changes/chg_44a1e0/lifecycle/approve" \ -H "authorization: Bearer $NICIA_KEY" \ -H "content-type: application/json" \ -d '{"evaluationOverrideReason": "The judge misread the refund window; checked by hand."}' ``` Without one, approve answers `400 validation`. A check still running answers `409 conflict` and cannot be overridden. An answer published over an override is never reported `verified`; its `verification` keeps the outcome and your reason. This gate governs Review only. Whether an agent’s write enters Review at all is the Brain’s admission level (above), and an answer an agent writes directly is served as `not_evaluated`. Publishing never overwrites a newer write. Every record a change was staged against is re-checked when it publishes, and if any of them was edited, created or deleted since staging, nothing is applied: publish answers `409 conflict` naming each record that moved, and the change’s lifecycle becomes `blocked`, with `blockedReason: "candidate_base_moved"` and `blockedRefreshAction: "restage_against_current_state"`. Approving it again cannot help — have the producer write the change again against the current records, then review that one. A blocked change can still be rejected or cancelled. Rejecting takes a reason, which is required — a rejection is never silent — and which `GET /changes?status=rejected` reports back: ```bash curl -X POST "$API/v1/brains/tenant:acme/changes/chg_44a1e0/lifecycle/reject" \ -H "authorization: Bearer $NICIA_KEY" \ -H "content-type: application/json" \ -d '{ "reason": "Title is from a signature block, not a statement." }' ``` Preview — partial approval may change without notice Selecting operations (`select`), and scheduling, cancelling and reading the journal of a change, are preview operations: they work, but their shape may change or be removed without notice. Approve, reject and publish above are stable. Approve part of a change by selecting operations. `GET /changes/{changeId}/lifecycle` lists the change’s operations (one per record written or deleted, with the operations each depends on), and `select` partitions them: ```bash curl -X POST "$API/v1/brains/tenant:acme/changes/chg_44a1e0/lifecycle/select" \ -H "authorization: Bearer $NICIA_KEY" \ -H "content-type: application/json" \ -d '{ "selectedOperationIds": ["…"], "omittedOperationIds": ["…"] }' ``` The answer names a `successorChangeId` (what you selected) and a `remainderChangeId` (what you set aside, still pending). The original change is superseded and can never apply in full, so approve and publish the successor; the remainder stays in the queue until someone decides it. Selection is per record, not per field, and it does not edit values: when the substance is right and the wording is not, reject with a reason and have the corrected value written. Undoing a change that has already landed is a console action today, not an operation on this API surface. When it runs, it either restores the content or refuses the whole revert up front — it never marks a change reverted while leaving the content in place. ## Who approved what [Section titled “Who approved what”](#who-approved-what) Planned — ships with the kernel Not shipped yet. `change.author` names who *proposed* a write (D7); who settled it is not yet on the wire. Attribution is derived from the key that made the call — never from a field somebody filled in. “Who approved this” will be answered by the Brain, not by your logging. ## Reviewing without a person [Section titled “Reviewing without a person”](#reviewing-without-a-person) Review is a checkpoint, not necessarily a human. A key with the `review` capability can settle changes, so you can put your own evaluator in front of the queue and only escalate what it is unsure about. ```ts const changes = `${API}/v1/brains/tenant:acme/changes`; const auth = { authorization: `Bearer ${NICIA_KEY}` }; // One page of the queue; walk the rest with `data.nextCursor`. const { data } = await fetch(`${changes}?status=staged`, { headers: auth, }).then((response) => response.json()); for (const change of data.changes) { const verdict = await myEvaluator(change); if (verdict.confident) { const lifecycle = `${changes}/${change.id}/lifecycle`; await fetch(`${lifecycle}/approve`, { method: "POST", headers: auth }); const published = await fetch(`${lifecycle}/publish`, { method: "POST", headers: { ...auth, "content-type": "application/json", "idempotency-key": `publish-${change.id}`, }, body: "{}", }); if (!published.ok) { const { data: state } = await fetch(lifecycle, { headers: auth }).then( (response) => response.json(), ); if (state.lifecycle.blockedReason === "candidate_base_moved") { // A record it was staged against moved since staging. Re-run // whatever produced the change against the current records. await restage(change); } else { throw new Error(`publish ${change.id}: ${String(published.status)}`); } } } // leave the rest for a person } ``` ## The Brain decides, not the caller [Section titled “The Brain decides, not the caller”](#the-brain-decides-not-the-caller) `mode` sets the policy’s default outcome — it is not a per-write switch. A key cannot ask for its writes to skip review, and no request body can either. The Brain’s admission policy evaluates the write’s consequence, magnitude, duplicates, and conflicts at the selected level. “Wait” means the change stages until a human or a trusted service settles it; “land” means it is admitted immediately and shows up in `/changes`. L1 keeps all agent output in Review. L2 admits only trivial changes. L3 admits routine new, non-conflicting writes while retaining consequence gates. L4 turns those gates off, including the destructive-write hold, so use it only for a pipeline you explicitly trust. Human writes land at every level, except a snapshot’s change to a value a person or another source supports. If you want a trusted pipeline writing straight in while agents are reviewed, give them separate Brains rather than caller-specific exceptions inside one. A Brain is cheap, its handle is yours to choose, and two Brains with different levels make the trust boundary explicit. ## Notifications [Section titled “Notifications”](#notifications) Planned — ships with the kernel Not shipped yet. Register a webhook so a staged change reaches your queue instead of waiting to be polled: ```bash curl -X POST "$API/v1/brains/tenant:acme/webhooks" \ -H "authorization: Bearer $NICIA_KEY" \ -H "content-type: application/json" \ -d '{ "url": "https://ops.example/nicia", "events": ["change.staged"] }' ``` ## Next [Section titled “Next”](#next) * **[Receipts](/docs/govern/receipts/)** — proving what a read was shown. * **[Handing out keys](/docs/scale/brain-keys/)** — who gets to write without review. # Context > One prompt in, grounded text and citations out. The endpoint most integrations only ever need. `/context` is the reason to use Nicia. You send the question your agent is about to answer; you get back text to put in the prompt, the citations behind it, and a receipt that proves what was shown. ```bash curl -X POST "$API/v1/brains/tenant:acme/context" \ -H "authorization: Bearer $NICIA_KEY" \ -H "content-type: application/json" \ -d '{ "prompt": "What should I know before the renewal call?", "maxTokens": 2000 }' ``` ```json { "data": { "text": "Acme renewed at $120k for 24 months. Bob Smith (VP Platform) asked for SSO before Q3.\n\nCustomer reports a redirect loop after switching to Okta. Resolved same day.", "citations": [ { "source": "hubspot:deal:412", "slug": "brain-evidence-0dd3e2449851d9ba41924c42bd7a61375a8b78050bfe2ca7192eb918d626236a", "quote": "Acme renewed at $120k for 24 months. Bob Smith (VP Platform) asked for SSO before Q3.", "recordedAt": "2026-03-14T18:02:11Z" }, { "source": "zendesk:ticket:8821", "slug": "brain-evidence-3bb86259c42d0261e0f1cc2d0f7f5f6bb2ee2a9a1cfa8a5b1c5e8a5f0f2a7d31", "quote": "Customer reports a redirect loop after switching to Okta. Resolved same day.", "recordedAt": "2026-08-02T14:20:00Z" } ], "staged": 0, "warnings": [], "receipt": "rcp_019ffefe", "truncated": false } } ``` Drop `text` into your system prompt. Render `citations` in your UI. Log `receipt`. To read a branch, include `"branch": ""` in the request body. A branch-scoped Brain key uses its branch when you omit it; a main-scoped key may name a branch explicitly. The answer is pinned to that branch’s head and its receipt names the branch. Branch documents and records are served there while main continues to serve its own accepted versions. A branch read includes an `access_scoped` warning to make that limited view explicit. [Branches and Proposals](/docs/write/proposals/) describes how reviewed changes reach main. `source` is the id **you** wrote the document under, and it is the address to act on: it round-trips into `GET /documents/{documentId}` and into your own links. `slug` is the Brain’s internal address for the same page — an opaque hash, derived from the Brain and your document id, for anything written through this API — and it is there to match a citation against a [`/search`](/docs/read/search/) hit, never to be parsed or displayed. A document that never arrived through this API (a page authored in the console) carries `slug` alone. ```ts const response = await fetch(`${API}/v1/brains/tenant:acme/context`, { method: "POST", headers: { authorization: `Bearer ${NICIA_KEY}`, "content-type": "application/json", }, body: JSON.stringify({ prompt: userQuestion, maxTokens: 2000 }), }); const { data } = await response.json(); const answer = await model.complete({ system: `Use only the context below.\n\n${data.text}`, messages: [{ role: "user", content: userQuestion }], }); ``` ## What `text` is [Section titled “What text is”](#what-text-is) One block per source, blank-line separated, in the same order as `citations`: * a **document that carries frontmatter** contributes a name line — the `title` in its own frontmatter, or the Brain’s name for the page (`Evidence `) when it has none — then every frontmatter key as a `key: value` line, then a blank line, then its prose. A document with no frontmatter is its prose and nothing else; * a **record** contributes its label and kind (`Acme (company)`, or its id when it has no `label`) and then its values, one `field: value` per line; * a page with values but no prose — a record written on a Brain’s free-form kind — contributes its label and those values. Frontmatter is in the block on purpose: it is what the page says about itself, and a Brain never drops an authored field. It is worth knowing before you paste `text` into a prompt — a document carrying an `internal_owner:` line puts that line in front of the model. A document’s prose is the whole document unless it was too large for the budget, in which case it is the passages your prompt matched with `[…]` where text was left out — see [Budget, not page size](#budget-not-page-size). Its name line and its frontmatter are unchanged either way. Block *n* of `text` is `citations[n].quote`, character for character — `text` is those quotes joined by a blank line and nothing else. That is the property to lean on: whatever you show a user as a citation is exactly what the model was shown, so a quote can never be a paraphrase of the answer or a subset of it. Nothing internal to the Brain’s storage — its opaque ids, its file paths — appears in either. Read the blocks from `citations` rather than by splitting `text`: prose can contain blank lines of its own. ## Budget, not page size [Section titled “Budget, not page size”](#budget-not-page-size) `maxTokens` is a budget, not a row count. Nicia orders sources by **relevance to your prompt** and fills until the budget is spent, so what a small budget buys you is the material your question actually named. Two things decide that order, fused. A record ranks because a **declared field** holds something your prompt named — which is how a record with no prose at all is retrievable — and because the **passage index** ranked its text. Neither is a penalty for the other: a typed row your prompt names by customer is found with no prose in the Brain, and a document your prompt matches only in wording is found with no field in common. Sources nothing matched keep a stable order keyed on each source’s id, so the tail never varies. A third stage may then re-judge the top of that list. Where a Brain is served by a deployment with a **reranker**, a cross-encoder reads your prompt and each leading candidate *together* and scores how well that candidate answers it — the one signal the two legs above cannot give you, because neither ever looks at the prompt and the record at the same time. It re-orders the head; everything past it keeps its fused order. The unit it judges is still the source, so a typed record with no prose is reranked on its declared values like anything else. That means a model can judge relevance, and you can tell whether one did. The snapshot on every answer carries the retrieval models it was served under — the embedding model, and the reranker or `none` — so “which models produced this text” is recorded rather than assumed. A Brain on a deployment with no reranker is ordered by the two legs alone and says `none`, which is what the whole surface did before reranking existed. What does not depend on any of that is the receipt. It records the exact sources and versions an answer rested on, so any answer stays reproducible from its receipt whatever changes afterwards — including a change of ranking model. That is the guarantee to build on: not that the ranker is a fixed computation, but that what you were shown is written down. Truncation follows that order, so what gets left out is the material your prompt matched least — but “least” is not “not at all”: ```json { "data": { "truncated": true, "omitted": 12, "warnings": [{ "kind": "truncated", "count": 12 }] } } ``` `truncated` says the budget ran out; `omitted` counts the sources that never made it in — counted against everything the Brain holds, so a source the read never selected as a candidate counts exactly like one that did not fit. Read a `true` as “you have not seen everything”, never as “the rest did not matter”. Raise the budget, or go to [`/query`](/docs/read/search/#query--rows) for the specific rows you need. A **document** too large to serve entire contributes the passages your prompt matched — the ones the passage index has already read, which is also what earned it its place in the order — rather than contributing nothing. `[…]` marks every place the render left text out: before the first passage when it is not the start of the document, between two passages that were not adjacent, and at the end. `truncated` is `true` for that answer even when `omitted` is `0`, because you have the parts of that document your question named and not the document. A **record** is never excerpted. It is its declared values, and there is nothing to leave out of them, so a record renders whole or does not render — which is also why a Brain of typed records with no prose in it behaves identically whether or not anything has ever been indexed. A source that fits neither route — too large for the budget, with no matched passage small enough to stand for it — is skipped rather than treated as a stop signal, so one oversized source cannot empty an answer it happens to lead. Skipped sources count toward `omitted`. The default budget is 2048 tokens and the ceiling is 8192 — both the current deterministic assembler’s own numbers, and both will rise. Asking for more than the ceiling is a `400` naming it, never a quiet clamp: a request that was answered at a budget you did not choose, with `truncated: true` on it, is indistinguishable from one whose budget genuinely ran out. ```json { "error": { "kind": "validation", "message": "maxTokens: maxTokens must be at most 8192, which is the largest budget the deterministic context assembler renders to." } } ``` ## No filter, on purpose [Section titled “No filter, on purpose”](#no-filter-on-purpose) `/context` answers from the whole Brain. There is no `filter` parameter: the prompt and the budget are the selection. The read selects its candidates in a bounded pass rather than loading the Brain — records whose field values hold the prompt’s terms, best-matched first; the documents the passage index ranks for the prompt; and the remaining records in a stable order, page by page, until the budget is spent — so a large Brain is served at the same cost as a small one and is never refused for its size. Narrowing lives on [`/query`](/docs/read/search/#query--rows), where a predicate is checked against a **declared** field and an unsupported one comes back as a `400` naming it. Filtering prose retrieval would mean filtering on document metadata that no read indexes — so rather than accept a `filter` and quietly ignore it, `/context` refuses one: ```json { "error": { "kind": "validation", "message": "Unrecognized key: \"filter\"" } } ``` A silently ignored filter is worse than none: you would get a confident, well-formed, whole-Brain answer to a question that asked for a slice, and nothing in the response would tell you. ## No subject scoping either [Section titled “No subject scoping either”](#no-subject-scoping-either) `about` is refused for the same reason, and it is the one most worth explaining. It read as “you already know what this is about, so walk the relationships from there” — and there is no traversal behind it. Context assembly selects sources by the prompt, orders them, and renders until the budget is spent; there is no subject coordinate anywhere in that. Accepted and ignored, it would have answered `200` with a whole-Brain answer to a question that named one account. ```json { "error": { "kind": "validation", "message": "Unrecognized key: \"about\"" } } ``` To narrow to one record, read it with [`/query`](/docs/read/search/#query--rows). ## Sources disagree — and `/context` says so [Section titled “Sources disagree — and /context says so”](#sources-disagree--and-context-says-so) When two sources make incompatible claims over the same window, Nicia retains both facts. The read names the disagreement: `warnings[]` kind `contested` plus per-item `status`. There is no `conflicts[]` field on `/context`. ```json { "data": { "text": "acme\ncontract_value_usd: 120000", "citations": [ { "slug": "stmt-salesforce-arr", "quote": "acme\ncontract_value_usd: 120000", "recordedAt": "2026-08-14T09:20:00.000Z" } ], "items": [ { "kind": "record", "id": "stmt-salesforce-arr", "fields": { "contract_value_usd": 120000 }, "status": "contested" } ], "staged": 0, "warnings": [ { "kind": "contested", "subject": "acme", "predicate": "contract_value_usd", "count": 2 } ], "receipt": "rcp_contested", "truncated": false } } ``` `GET /findings` is the operator inbox — list, then decide with `POST /v1/brains/{handle}/findings/{findingId}/decide` and an `Idempotency-Key`. The integrator already has the signal from the read. See [Statements](/docs/write/statements/). ## A standing answer whose grounds moved [Section titled “A standing answer whose grounds moved”](#a-standing-answer-whose-grounds-moved) A standing answer is a reviewed answer to a question people keep asking. It rests on claims, and the claims were read from documents. Every read that serves a standing answer serves its `validity` beside it: * `current`: nothing Nicia monitors for this answer has moved since it was accepted. * `needs_review`: something it rests on moved. `reasons` names each claim that changed or was removed, each claim whose quoted source text changed, and each new claim that arrived from a document the answer already uses. * `unknown`: Nicia cannot tell. For example, the answer declares no claim it rests on, part of what it rests on carries no evidence to check, or the check could not read everything it monitors. ### What an answer rests on [Section titled “What an answer rests on”](#what-an-answer-rests-on) Nicia does not interpret a document’s prose when it arrives: the text is served and indexed as it is. A standing answer rests on the **sections** it cites. The answer’s composer cites one `statement` per document section the answer depends on, each pinned server-side to that section, and the answer `depends_on` exactly those citations. A section is cited by its heading, as `§ `, and pinned as everything under that heading, subsections included. An edit anywhere inside a cited section marks the answer `needs_review`; an edit to another section, or lines added above it, does not. * **Only markdown headings count.** A document with no headings, such as text converted from a PDF, can be cited only by line range, and a line range cannot see a sentence added just after the cited lines. Convert documents so that their headings are kept as markdown headings. * **Only the composer’s citations are monitored.** A standing answer your own agent writes through the public API, even one that cites a statement with verified `evidence`, reads `unknown` with reason `support_unmonitored`, and `/context` withholds its text. Answers that validity monitors come from the maintained-knowledge setup in the console. * **Rebuilds are not automatic on a document edit.** Validity flags the answer and `/context` withholds it at once, but a replacement is proposed only when someone recomposes it from the Governed answers panel. Grounds are measured from `affirmed`: the last time someone decided the answer still holds. That is its creation, a person’s write to it, or a Review decision on it. An agent’s rewrite that the Brain’s policy lands without review changes the answer (`accepted` moves) but does not clear grounds that moved, so the answer stays `needs_review` until a person or a reviewer affirms it. Beside `validity`, every standing answer carries `verification`: whether the version you are served passed its regression check before it was published. `verified` needs a passing check for that exact revision and no override; `overridden` keeps the check’s outcome and the reviewer’s reason; an answer an agent wrote directly, without Review, is `not_evaluated`, and never inherits an earlier version’s verdict. The two are independent: an answer can be current and not evaluated, or verified and since overtaken by its grounds. Only `validity` decides whether `/context` serves the text. `current` is not a claim about the world. It means the monitored scope did not move through `observedThrough`, and `monitored` lists that scope: the claims the answer rests on and the documents they were read from. It is not a regression check either; `validity` says nothing about whether the answer was evaluated. On `/context`, an answer that is not `current` is served **without its text**, as its question and `validity` only, with a warning of kind `answer_not_current`. An agent assembling context cannot mistake an answer whose grounds moved for the current one. The last accepted text stays available to direct reads, a view read, or an export, each with the same `validity`. Once the grounds move, `rebuild` reports what the Brain’s rebuild automation has done: `not_attempted`, `running`, `failed`, `proposed` with the waiting change, or `completed_without_proposal`. A failed rebuild never makes the old answer look current. ```json { "data": { "text": "Refund answer (standing_answer)\nquestion: How long do customers have to ask for a refund?\nvalidity: needs_review — NOT a current answer; its text is withheld (input_changed refund-window; monitored 1 claim: refund-window; sources: refund-policy through log position 14)\nverification: verified — this version passed its regression check", "citations": [ { "slug": "refund-answer", "quote": "Refund answer (standing_answer)\nquestion: How long do customers have to ask for a refund?\nvalidity: needs_review — NOT a current answer; its text is withheld (input_changed refund-window; monitored 1 claim: refund-window; sources: refund-policy through log position 14)\nverification: verified — this version passed its regression check", "recordedAt": "2026-09-28T09:00:00.000Z" } ], "items": [ { "kind": "record", "id": "refund-answer", "fields": { "question": "How long do customers have to ask for a refund?" }, "validity": { "state": "needs_review", "reasons": [ { "kind": "input_changed", "recordId": "refund-window", "changed": { "position": 14, "at": "2026-09-28T10:30:00.000Z" } } ], "accepted": { "position": 9, "at": "2026-09-28T09:00:00.000Z" }, "affirmed": { "position": 9, "at": "2026-09-28T09:00:00.000Z" }, "monitored": { "claims": ["refund-window"], "sources": ["refund-policy"] }, "observedThrough": { "position": 14, "at": "2026-09-28T10:30:00.000Z" }, "rebuild": { "state": "failed", "at": "2026-09-28T10:30:05.000Z", "reason": "Model provider unavailable" } }, "verification": { "state": "verified", "changeId": "chg_7Qm2", "evaluation": { "state": "passed", "score": 91 } } } ], "staged": 0, "warnings": [ { "kind": "answer_not_current", "count": 1, "detail": "1 standing answer's grounds moved or cannot be checked since it was accepted, so its text is withheld; each item's `validity` says why." } ], "receipt": "rcp_answer_not_current", "truncated": false } } ``` `GET /records/{id}` and `POST /query` carry the same `validity` on a standing answer. `/search` returns a standing answer as a typed hit with the same `kind`, `validity` and `verification`, and shows one that is not `current` by its question alone. ## A record whose cited document moved [Section titled “A record whose cited document moved”](#a-record-whose-cited-document-moved) A citation pins the version of the document it quoted, which keeps the quote true after the document is revised. It also means a record can keep showing a sentence the document no longer says, or cites a document that was withdrawn. Every read that serves a record checks its citations against the version the Brain serves now, and says so when one no longer holds. A record carries `support` only when a cited document moved. It is absent when every cited document still serves the cited words, and when the record cites nothing. `state` is `source_withdrawn` when a cited document is no longer served, and otherwise `support_at_risk`, when the cited words are no longer in it. `reasons` names each document. ```json { "data": { "record": { "id": "embedded-payments-consolidation", "kind": "thesis", "fields": { "thesis": "Falcon will consolidate the segment" }, "links": {}, "token": "brain-record-token-v1:9d2f4b6a8c0e1f3a5b7c9d1e3f5a7b9c1d3e5f7a9b1c3d5e7f9a1b3c5d7e9f1a", "updatedAt": "2026-09-30T09:20:11Z", "support": { "state": "support_at_risk", "reasons": [{ "kind": "support_at_risk", "document": "falcon-memo" }] } }, "staged": 0, "warnings": [], "receipt": "rcp_01a0c3f2" } } ``` What `/context` does about it depends on the kind. A **statement** is exactly its cited words, so one whose document moved is served **without its text**, as its `support` only, as a standing answer that is not `current` is. Any other record is served whole and marked: its values are as last accepted, and the citation behind them no longer holds. Either way the read warns with `evidence_moved`, and its receipt records what was served, marker included. `GET /records/{id}`, `POST /query`, `/search`, a view read and the MCP `brain_context` and `brain_query` tools carry the same `support`; a direct read keeps the last accepted values beside it, and a search hit shows a withheld statement with an empty `excerpt`. The check is bounded: one lookup of what the cited documents serve now, and document text is read only for a document whose served version is not the one the citation pinned. A revision that leaves the cited words in place, an edit elsewhere on the page, is not a change to the support. A citation that named no words pinned nothing finer than the document, so only its withdrawal is detected. A branch read is not checked. `GET /receipts/{receiptId}/now` reports the same thing after the fact in `evidence[]`: each record the receipt served whose cited documents have since moved or been withdrawn, with the `support` it was served with (`servedSupport`, absent when it was served intact) and its `support` now, even though the record’s own version did not change. ## An answer your own agent maintains [Section titled “An answer your own agent maintains”](#an-answer-your-own-agent-maintains) Your agent does not need Nicia’s composer to keep a monitored answer. Over the API or MCP it reads a document, cites the part the answer rests on, and rests the answer on that citation: 1. List the documents with their outline (MCP `brain_documents`) and read the section. Every heading is listed as the exact anchor to cite. 2. Write a `statement` whose `evidence` names the document and the `anchor` (and, if you like, the `quote`). See [Cite your sources](/docs/write/records/#cite-your-sources). The Brain checks the anchor against the version it serves and computes the pins itself; you never send one. 3. Write the `standing_answer` with its `question`, its prose as `body`, and `depends_on` linking the statements it rests on. The answer then reads `current`, lists the statements and documents under `monitored`, and reads `needs_review` when a cited section changes. An edit to another section of the same document leaves it `current`. A statement cited by its quote alone, with no anchor, is stored and shown but not monitored, so an answer resting only on such statements reads `unknown`. ## An empty answer explains itself [Section titled “An empty answer explains itself”](#an-empty-answer-explains-itself) ```json { "data": { "text": "", "citations": [], "staged": 3, "warnings": [ { "kind": "staged_pending", "count": 3, "detail": "3 changes are waiting for review, so the Brain may be waiting on a human rather than knowing nothing." } ], "receipt": "rcp_31af09", "truncated": false } } ``` `staged` is the number of **changes** awaiting review — proposals, not the records inside them, so a batch of twenty records staged as one proposal counts `1`. It is the count the review queue itself is measured in, which is where a non-zero value sends you. A non-zero `staged` means the answer may be waiting on a human rather than missing, and that is the distinction to surface to your users: they are very different problems. It is usually `0` on an `open` Brain, whose writes are admitted as they arrive — but not always, because anything that proposes rather than writes (Nicia’s own Brain agent stages every change it suggests) puts work in the queue whatever the mode. `staged` can also be `null`, which means the count could not be taken on this read. The answer and its receipt are unaffected; what you cannot conclude from a `null` is that nothing is waiting. It is nullable for exactly that reason — reporting a failed count as `0` would say “the Brain knows nothing relevant” on a Brain that is holding your material for a reviewer. ## Why an answer is less than the whole truth [Section titled “Why an answer is less than the whole truth”](#why-an-answer-is-less-than-the-whole-truth) `warnings` is present on every read and empty when the answer is complete. There are seven kinds. Four are operational; `contested` means sources disagree, `answer_not_current` means a standing answer was withheld, and `evidence_moved` means a record cites a document that moved or was withdrawn: | Kind | Means | | -------------------- | ------------------------------------------------------------------------------------------------------------------------------------------- | | `processing` | Evidence is accepted but not indexed yet, so a just-written document isn’t in this answer | | `access_scoped` | Your credential’s scope excluded matching content — published, not yet emitted by any read | | `truncated` | The token budget cut the answer short | | `staged_pending` | Matching content exists but is awaiting review | | `contested` | Two sources disagree about a subject and predicate in this answer | | `answer_not_current` | A standing answer matched, but its grounds moved or cannot be checked, so its text is withheld | | `evidence_moved` | A record matched, but a document it cites moved or was withdrawn; a statement’s text is withheld, any other record is marked with `support` | `count` and `detail` are optional; `detail` is one sentence written to be safe to show an end user. `contested` is the sources-disagree signal: the answer is not settled. Wire it when an agent is about to act on a number two sources still fight over. `staged_pending` is the other one worth wiring today: it is the difference between “the Brain knows nothing” and “the Brain is waiting on a human”. `access_scoped` is enumerated and emitted by nothing — a Brain key is scoped to a whole Brain, and nothing filters an answer by credential. It stays published so a client written today renders it the day a read does. `warnings` carries the same kinds on `/search` and `/query`; only `/context` withholds an answer, so only `/context` emits `answer_not_current` and `evidence_moved`. `processing` counts what is outstanding **for the read you made**, and the same word means two different things depending on the read. Nothing is ever withheld from `/context` or `/query` for it. A page is in an accepted-state answer as soon as the Brain accepts it — typed or not, indexed or not, and whether or not your schema declares a kind that fits it. On `/search`, by contrast, a document whose text is not in the passage index yet is genuinely absent from the results, because ranking passages is all that endpoint does. What `processing` tells you on `/context` is that part of what you were shown was **ordered without its own text being read**. Relevance ordering uses the passage index, so a document still being chunked can only rank on its declared fields; it is in the answer, but under a tight budget it may not have made the cut. That is why the sentence in `detail` says “may not include” rather than “is not in this answer”. A record with no prose is never counted: it has no passages, its declared fields are the whole of what it says, and it is fully rankable the moment it is written. It is the warning to branch on after a write: an empty `/search` with a `processing` warning means “not yet”, and an empty one with no warnings means “not here”. Branch on the warning, not on its `count`: the index check looks at your most recent writes, which is where an unindexed document is, so a very large backlog reports what it looked at rather than the total. The same honesty rule `staged: null` follows applies here — a count that could not be taken emits no warning rather than a `processing: 0` claiming nothing is outstanding, so `warnings` is what the read knows, never a promise that it looked. ## Rendering it yourself [Section titled “Rendering it yourself”](#rendering-it-yourself) If you want the pieces rather than the prose, ask for items and render your own prompt block: ```bash -d '{ "prompt": "…", "format": "items" }' ``` ```json { "data": { "text": "", "citations": [ { "source": "hubspot:deal:412", "slug": "brain-evidence-0dd3e2449851d9ba41924c42bd7a61375a8b78050bfe2ca7192eb918d626236a", "quote": "Acme renewed at $120k for 24 months.", "recordedAt": "2026-03-14T18:02:11Z" } ], "items": [ { "kind": "document", "id": "hubspot:deal:412", "text": "Acme renewed at $120k for 24 months." }, { "kind": "record", "id": "acme", "fields": { "name": "Acme", "arr": 120000 } } ], "staged": 0, "warnings": [], "receipt": "rcp_019ffefe", "truncated": false } } ``` `text` is empty because you asked for the pieces instead. `citations` and the receipt come back either way, so a custom renderer does not cost you provenance. Switch on `kind`. A `document` is a page nothing has typed and carries `text`, rendered the same way `/context` renders it. A `record` is a typed row and carries `fields` — the same values `/query` returns, as numbers and booleans rather than as YAML you would have to pull back out of a string — addressed by the id `PATCH /records/{recordId}` takes, so an item round-trips into a write. The split is your schema’s, not a guess at one. A piece is a `document` when it is filed under the kind your Brain uses for things nobody has typed — your own free-form kind, the one you declared without fields, or `document` when you declared none — and a `record` otherwise, as long as it carries a value beyond its prose. So a kind you declare called `document` (or `note`) and give fields to is **your** kind: its records arrive as `record`, with your fields. ## Cost and latency [Section titled “Cost and latency”](#cost-and-latency) One `/context` call is one round trip and typically 200–500 ms. It does not call a language model to write anything: nothing is summarised, paraphrased or generated, so every word you get back is a word your Brain already held. On a deployment with a reranker, the ranking stage does score the leading candidates with a small cross-encoder inside that same round trip; on one without, no model runs at all beyond embedding your prompt. ## Next [Section titled “Next”](#next) * **[Search and queries](/docs/read/search/)** — when you want rows, not prose. * **[Receipts](/docs/govern/receipts/)** — what that `receipt` string is worth. # Search and queries > Full-text and semantic search when you want passages, and exact predicates when you want rows. [`/context`](/docs/read/context/) is the right call for “what should my agent know.” These two are for when you want the raw material instead. Both endpoints can read a branch. Add `branch=` to `/search`, or `"branch": ""` to the `/query` body. A branch-scoped key selects its branch implicitly. The record and declared-field indexes are scoped to the selected branch, so a field predicate on `/query` cannot return a record that exists only on main. Both answers include `access_scoped` when a branch limits the read, and their receipts pin the selected branch. ## Search — passages [Section titled “Search — passages”](#search--passages) ```bash curl "$API/v1/brains/tenant:acme/search?q=sso+redirect+loop&limit=5" \ -H "authorization: Bearer $NICIA_KEY" ``` ```json { "data": { "results": [ { "source": "zendesk:ticket:8821", "slug": "brain-evidence-3bb86259c42d0261e0f1cc2d0f7f5f6bb2ee2a9a1cfa8a5b1c5e8a5f0f2a7d31", "title": "SSO login loop after IdP change", "excerpt": "Customer reports a redirect loop after switching to Okta…", "rank": 1, "version": 3 } ], "mode": "fulltext", "staged": 0, "warnings": [], "receipt": "rcp_019fff21" } } ``` Search ranks the passages of everything the Brain holds, best match first. Use it to build your own retrieval flow, to show a “sources” panel, or to debug why `/context` answered the way it did. Document passages come first, then the typed records that match, each in its own ranked order. A typed record’s hit carries its `kind`; a document passage has none. A [standing answer](/docs/read/context/#a-standing-answer-whose-grounds-moved) is a typed hit that also carries `validity` and `verification`. One whose grounds moved, or cannot be checked, is shown by its question alone: its text is withheld from `excerpt`, exactly as `/context` withholds it. An answer’s text never arrives as an untyped document passage. A typed hit on a record that cites a document which has since moved or been withdrawn carries `support`, and a statement in that state has an empty `excerpt`, as [`/context`](/docs/read/context/#a-record-whose-cited-document-moved) serves it. ### How a query matches [Section titled “How a query matches”](#how-a-query-matches) `q` is read as a web-search query, with English stemming, the same way for documents and for records: * **Every word must match.** `sso redirect` finds what mentions both, not either one. * **Words match by stem, not by substring.** `redirects` matches `redirect`, but `direct` does not match inside `redirect` — there is no mid-word match. * **`"quoted phrase"`** matches those words in that order. * **`or`** between two words matches either: `okta or saml`. * **`-word`** excludes what contains it: `sso -okta`. A record is matched on its name and slug, its field values and its prose, and a match in its name outranks one in a field, which outranks one in its prose. Search reads a bounded, index-ranked selection however large the Brain is; it never refuses a Brain for its size. `rank` is a position, not a score: `1` is the best match. Ranking fuses a keyword leg and a semantic leg into an ORDER, and there is no comparable relevance number underneath to hand you. `version` is the exact document version the passage was cut from, and it is what the `receipt` records. `source` is the id you wrote the document under — the address to act on, and the one that round-trips into `GET /documents/{documentId}`. `slug` is the Brain’s internal address for the page (an opaque hash, derived from the Brain and your document id, for anything written through this API), and it is what lets you line a hit up against a `/context` citation. `mode` is the whole narrowing vocabulary: | `mode` | Ranks by | | -------------------- | --------------------------------------------------------------- | | `fulltext` (default) | Keywords. No embedding round trip, so it is the fast path | | `hybrid` | Keywords and meaning, fused — matches phrasings you did not use | The response echoes the `mode` it **actually** ranked by. `hybrid` needs an embedding provider, and a deployment without one answers your `hybrid` request with keyword ranking rather than failing a read it can still serve — so `data.mode` comes back `fulltext` and you can tell a fused answer from a keyword one. Check it before you conclude that semantic matching did not help. Search takes no filter, exactly like [`/context`](/docs/read/context/#no-filter-on-purpose). Ranking reads a passage index, and that index knows nothing about your document metadata. When you want exact resolution rather than a ranking — this kind, this `slug`, every gold-tier site whose contract ends this quarter — that is `/query` below. Ranked retrieval and typed filtering are different tools, and Nicia will not pretend one is the other by accepting a filter it cannot apply. ## Query — rows [Section titled “Query — rows”](#query--rows) When you want records by their declared fields rather than ranked passages: ```bash curl -X POST "$API/v1/brains/tenant:acme/query" \ -H "authorization: Bearer $NICIA_KEY" \ -H "content-type: application/json" \ -d '{ "kind": "person", "where": { "slug": "bob-smith" }, "limit": 20 }' ``` ```json { "data": { "records": [ { "id": "bob-smith", "kind": "person", "fields": { "name": "Bob Smith", "role": "VP Platform" }, "sources": ["zendesk:ticket:8821"], "token": "brain-record-token-v1:9f2c4b6a1d0e8f735a4c2b9e1d6f0a3c8b5e7d2a4f6c9b1e3d5a7f0c2b4e6d8a", "updatedAt": "2026-08-02T14:20:00Z" } ], "staged": 0, "warnings": [], "receipt": "rcp_019fff44" } } ``` Every **declared** field is filterable, and every predicate is answered from a typed index — never a scan. A bare value is equality; an operator object names one or more operators on the same field: | Operator | Applies to | Matches a record that… | | ------------------------------------ | --------------------------------------------------------------------- | --------------------------------------- | | `"gold"` or `{ "eq": … }` | `string`, `enum`, `number`, `boolean`, `date`, `datetime`, and `slug` | holds exactly that value | | `{ "ne": … }` | the same scalar types | holds the field, with any other value | | `{ "in": [ … ] }` | the same scalar types, and `slug`; up to 50 values | holds one of the values | | `{ "has": … }` | `string_list` | holds that exact element | | `{ "lt" / "lte" / "gt" / "gte": … }` | `number`, `date`, `datetime` | holds a value on that side of the bound | A query ANDs at most eight predicates; each operator counts as one, so `{ "gte": "2026-10-01", "lt": "2027-01-01" }` spends two. A record that does not hold a field matches no operator on it, `ne` included. The question “gold sites whose contract ends before December” is one request: ```json { "kind": "site", "where": { "tier": "gold", "contract_end": { "lt": "2026-12-01" } }, "limit": 50 } ``` Nothing normalizes text: `"Gold"` does not match `"gold"`, and nothing is trimmed. A `date` operand is a calendar day (`YYYY-MM-DD`); a `datetime` operand is an instant with its offset, compared as the instant it names — see [Schemas](/docs/schemas/overview/#dates-and-instants). What is refused, always with a `400` naming the field and never with an empty page: * a field the Brain does not declare (for that `kind`, when you name one). Undeclared values are stored and returned; declaring the field is what indexes it; * an operator the declared type does not take (`lt` on an `enum`, `has` on a `string`), or an operand it cannot hold (`"12/01/2026"` for a `date`); * `400 index_not_ready`, only for a declared field whose index is being rebuilt because its declaration just changed — a field declared after records carrying it were written, for example. Retry shortly; every other field stays queryable meanwhile. `linked` narrows to the records with one declared link to one exact `kind:identity` target, then applies `where` inside that set. There is no `or`, no ordering by a field, and no multi-hop traversal. ## Paging [Section titled “Paging”](#paging) `/query` is keyset-paged with an opaque cursor. Its `limit` may ask for at most 100 records — that is the reader’s page, and a larger one is a `400` rather than a page quietly cut down to size: ```bash curl -X POST "$API/v1/brains/tenant:acme/query" \ -H "authorization: Bearer $NICIA_KEY" \ -H "content-type: application/json" \ -d '{ "kind": "person", "cursor": "eyJrIjoi…" }' ``` ```json { "data": { "records": [], "staged": 0, "warnings": [], "receipt": "rcp_019fff44", "nextCursor": "eyJrIjoi…" } } ``` Pages walk records in codepoint order of their `slug` — byte order, so `a-c` comes before `ab` — with or without `where`. Cursors are stable under concurrent writes: a record added mid-walk will not shift the page boundary and cause you to skip one, and a record that matched on every page you read appears exactly once. A record that starts or stops matching while you walk appears if it matches when its page is read. `/search` is **not** paged. It is a ranked top-N read with no stable cursor to hand out, so it takes a `limit` of at most 20 and returns the best matches for your query. Ask a narrower question rather than walking a ranking — a cursor here would have served you the same first page over and over. ## Receipts [Section titled “Receipts”](#receipts) Search and query both issue receipts, exactly like `/context`. Anything that shows a customer information can be reconstructed later, whichever endpoint produced it. See [Receipts](/docs/govern/receipts/). ## Which one do I want? [Section titled “Which one do I want?”](#which-one-do-i-want) * **Putting knowledge in a prompt** → [`/context`](/docs/read/context/). It selects, budgets, and renders for you. * **Showing sources, or building your own selection** → `/search`. * **Filtering records by declared fields for a table, dashboard, or rule** → `/query`. If you are reaching for `/search` to then paste the results into a prompt, use `/context` with `format: "items"` instead — you get the same pieces plus the budgeting. ## Next [Section titled “Next”](#next) * **[Context](/docs/read/context/)** — the prompt-shaped read. * **[Schemas](/docs/schemas/overview/)** — declaring the fields `/query` filters on. # Ship a context layer in an afternoon > A worked migration for an AI agent company — from your own RAG plus a records table to one Brain per customer, in two API calls. This page follows one fictional company all the way through, because the shape of the problem matters more than any single endpoint. If you would rather run the calls than read the argument, [Build an agent company on Nicia](/docs/tutorials/agent-company/) is the same journey as a worked tutorial, with the limits named. **Cadence** sells an AI customer-success copilot to B2B SaaS teams. Every one of their customers has their own accounts, contacts, renewals and support history, and the copilot is only as good as what it knows about *that* customer. Cadence already built the obvious thing: a records table in Postgres, a RAG index over uploaded documents, and a prompt assembler that stuffs both into a context window. It works. It is also the part of the product they cannot staff. Every new customer needs isolation they hand-roll, every answer needs provenance they do not have, and every change an agent makes lands in production with nobody able to say what changed or put it back. They do not need Nicia to replace their agent. They need it to be the layer underneath it. ## Two calls to a working Brain [Section titled “Two calls to a working Brain”](#two-calls-to-a-working-brain) Provisioning is a pure function of Cadence’s own tenant id. There is no id to store and no create-versus-update decision to make. ```bash curl -X PUT "$API/v1/brains/tenant:acme" \ -H "authorization: Bearer $NICIA_KEY" \ -H "content-type: application/json" \ -d '{ "name": "Acme", "schema": "nicia-core" }' ``` Call it on every request if you like — the same handle always resolves to the same Brain, `201` the first time and `200` after. Cadence’s provisioning code is one line in the signup path, and their reconciliation job is deleted. The second call puts something in it: ```bash curl -X PATCH "$API/v1/brains/tenant:acme/records/bob-smith" \ -H "authorization: Bearer $NICIA_KEY" \ -H "content-type: application/json" \ -d '{ "kind": "person", "fields": { "name": "Bob Smith", "role": "VP Platform" }, "links": { "works_at": ["acme"] } }' ``` That is the whole on-ramp. A Brain exists, it holds a typed record, and the copilot can ask it a question. ## The call the copilot actually makes [Section titled “The call the copilot actually makes”](#the-call-the-copilot-actually-makes) ```bash curl -X POST "$API/v1/brains/tenant:acme/context" \ -H "authorization: Bearer $NICIA_KEY" \ -H "content-type: application/json" \ -d '{ "prompt": "What should I know before the renewal call?", "maxTokens": 2000 }' ``` The answer comes back as assembled text plus citations, and every citation names the evidence it came from. Cadence’s prompt assembler goes away. So does the argument about whether the copilot made something up, because the receipt says what it was shown. ## Migrating what you already have [Section titled “Migrating what you already have”](#migrating-what-you-already-have) Cadence’s data is the common shape: **structured records with unstructured components**. They hold the records, and they hold the documents those records were derived from. Both belong in the Brain, and the relationship between them is the point. Send the documents your records came from: ```bash curl -X POST "$API/v1/brains/tenant:acme/documents" \ -H "authorization: Bearer $NICIA_KEY" \ -H "content-type: application/json" \ -d '{ "documents": [ { "id": "zendesk:ticket:8821", "title": "Okta redirect loop", "text": "Customer reports a redirect loop after switching to Okta. Resolved same day." } ] }' ``` Then write records that cite them. A record that names its source is a record whose Derivation panel can answer “why does the system believe this” — in the workspace and through the API. That is the thing Cadence could not build and could not skip. Migrating a customer is a loop over records, not a project. Writes are idempotent by address, so a half-finished migration is resumed by running it again. ## One schema for the whole fleet [Section titled “One schema for the whole fleet”](#one-schema-for-the-whole-fleet) Cadence’s customers all have the same shape, because Cadence decided the shape. Register that vocabulary once, then stamp it on every Brain: ```bash curl -X PUT "$API/v1/brains/tenant:acme" \ -H "authorization: Bearer $NICIA_KEY" \ -H "content-type: application/json" \ -d '{ "name": "Acme", "schema": "cadence-customer" }' ``` An organization’s own registered schema is nameable by slug exactly like a built-in. See [How schemas work](/docs/schemas/overview/#your-organizations-own-schema) for registering one, and [Custom schemas](/docs/schemas/custom/) for extending it per customer when one of them needs a field the others do not. Two reads keep a fleet honest as that vocabulary moves: * `GET /v1/schemas/{slug}/brains` — which Brains are on this schema. * `GET /v1/brains/{handle}/schema-lineage` — which baseline this Brain forked from, and whether that baseline has moved since. `unrecorded` means no lineage was recorded; it does **not** mean no drift. Both take an organization key rather than a Brain key, because both answer questions about the fleet rather than about one customer. ## Giving each customer their own key [Section titled “Giving each customer their own key”](#giving-each-customer-their-own-key) Cadence’s copilot runs per customer, and a credential scoped to one Brain is the difference between a bug and an incident. Mint a Brain key per customer and hand it to the agent process that serves them — see [Handing out keys](/docs/scale/brain-keys/). ## What Cadence deleted [Section titled “What Cadence deleted”](#what-cadence-deleted) * The prompt assembler, replaced by `/context`. * The per-customer isolation code, replaced by one Brain per tenant id. * The reconciliation job, because provisioning is idempotent on their own id. * The “what did the agent change” question, answered by [Review](/docs/govern/review/) and by change history. What they kept is the part that is actually their product: the copilot. # Handing out keys > Scoped keys for an app, a department, or an end user — and how to revoke one without touching anything else. A Brain key reaches exactly one Brain with exactly the capabilities you grant. It is what you hand to anything you do not run yourself. ```bash curl -X POST "$API/v1/brains/tenant:acme/keys" \ -H "authorization: Bearer $NICIA_KEY" \ -H "content-type: application/json" \ -d '{ "name": "Acme web app", "capabilities": ["read"] }' ``` ```json { "data": { "key": { "id": "key_7f3a1c", "name": "Acme web app", "capabilities": ["read"], "status": "active", "createdAt": "2026-08-14T09:41:02Z" }, "secret": "nbk_9f2c…" } } ``` **`secret` is shown once.** Nicia stores only its hash. Store it now; a lost secret is replaced by rotating, never recovered. ## Three shapes that cover most cases [Section titled “Three shapes that cover most cases”](#three-shapes-that-cover-most-cases) ### An app reading one customer’s Brain [Section titled “An app reading one customer’s Brain”](#an-app-reading-one-customers-brain) ```json { "name": "acme dashboard", "capabilities": ["read"] } ``` Read-only, one tenant. Safe in a server-rendered app. Never ship it to a browser — see [End users](#end-users) below. ### A pipeline writing into one Brain [Section titled “A pipeline writing into one Brain”](#a-pipeline-writing-into-one-brain) ```json { "name": "salesforce sync", "capabilities": ["read", "write"] } ``` A key says what this writer may reach, never how its writes are admitted. Whether a write lands or stages is the Brain’s `mode`, and the Brain’s admission policy decides it for every write, whoever sent it. See [Review](/docs/govern/review/). ### An agent working on one branch [Section titled “An agent working on one branch”](#an-agent-working-on-one-branch) Give the key a `branch` alongside its capabilities: ```json { "name": "draft agent", "capabilities": ["read", "write"], "branch": "branch-id" } ``` The branch must already exist. This key can write records on that branch, compare it with main, and open a [Proposal](/docs/write/proposals/) from it. It cannot read or write main, use main’s receipts or events, address another branch, or mint another key. Rotating it keeps the branch scope. Apply the Proposal with a reviewer key under main’s admission policy. A key scoped to a [read-only branch](/docs/write/proposals/#a-read-only-branch) is read-only: mint it with `["read"]`. Asking for any other capability, then or later, is a `409`. ### A department that owns its Brain [Section titled “A department that owns its Brain”](#a-department-that-owns-its-brain) ```json { "name": "finance", "capabilities": ["read", "write", "review", "admin"], "expiresAt": "2027-01-01T00:00:00Z" } ``` Corporate IT provisions a Brain per department with a management key, then hands each department a key that governs their own Brain and nothing else. `admin` is the review switch — `PATCH /v1/brains/{handle}`, which turns review on or off — but not the ability to read the Brain’s contents, which is a separate capability on purpose. That separation is only worth anything if it holds for bulk egress too, so `admin` does not carry export or webhook control either: export is served on `read`, and webhooks are not on the Brain-key surface today. The department above reads, and so can export, because it also holds `read`. Creating and deleting a Brain, listing the organization’s Brains, reading a Brain’s schema lineage, and the whole key plane — minting, listing, rotating, and revoking keys — stay with IT’s management key. No Brain key reaches any of them whatever capabilities it holds, so a department cannot mint sub-keys off the key it was given. See [Capabilities](/docs/start/authentication/#capabilities). ## Idempotent minting [Section titled “Idempotent minting”](#idempotent-minting) Derive the idempotency key from something stable on your side and a retried provisioning run returns the same key instead of minting a second live secret: ```bash curl -X POST "$API/v1/brains/tenant:acme/keys" \ -H "authorization: Bearer $NICIA_KEY" \ -H "content-type: application/json" \ -H "idempotency-key: app-key:acme" \ -d '{ "name": "acme app", "capabilities": ["read"] }' ``` A replay returns the original key record with `"secret": null` — the secret is still only ever shown at the moment of creation. ## Listing, rotating, revoking [Section titled “Listing, rotating, revoking”](#listing-rotating-revoking) ```bash curl "$API/v1/brains/tenant:acme/keys" \ -H "authorization: Bearer $NICIA_KEY" ``` ```json { "data": { "keys": [ { "id": "key_7f3a1c", "name": "Acme web app", "capabilities": ["read"], "lastUsedAt": "2026-08-14T11:58:03Z", "status": "active", "createdAt": "2026-08-14T09:41:02Z" } ] } } ``` `lastUsedAt` is how you find the keys nobody is using any more. The listing is keyset-paged like every other one: pass `limit` (up to 250) and follow `nextCursor` until it stops coming back. A first page is not the inventory — which matters most under the per-session pattern below, where one Brain accumulates a key per user. ```bash # Rotate: new secret now, old one keeps working for the overlap window curl -X POST "$API/v1/brains/tenant:acme/keys/key_7f3a1c/rotate" \ -H "authorization: Bearer $NICIA_KEY" # Revoke: immediate curl -X DELETE "$API/v1/brains/tenant:acme/keys/key_7f3a1c" \ -H "authorization: Bearer $NICIA_KEY" ``` Rotation takes no body: the overlap is a fixed 60 seconds, not a window you choose. It exists to cover the in-flight request while a running fleet redeploys — it does not replace writing the new secret to your secret store, and it is deliberately too short for a slow human redeploy. If you need an unbounded cutover, mint a second key instead of rotating. Revocation takes effect on the next request, everywhere. ## End users [Section titled “End users”](#end-users) Never put a Brain key in a browser, a mobile app, or anything else a user can open. Two safe patterns: **Proxy through your backend.** Your server holds the key, authenticates your user, and calls Nicia on their behalf. Pass a `consumerId` so you can later reconstruct exactly what one user was shown: ```bash curl -X POST "$API/v1/brains/tenant:acme/context" \ -H "authorization: Bearer $BRAIN_KEY" \ -H "content-type: application/json" \ -d '{ "prompt": "…", "consumerId": "user:4471" }' ``` `consumerId` is a correlation label you choose. It is never authorization — it does not restrict what the call can reach, so the scoping still has to come from the key. **Mint a short-lived key per session.** When a user genuinely needs to call Nicia directly: ```bash -d '{ "name": "session u_4471", "capabilities": ["read"], "expiresAt": "2026-08-14T13:00:00Z" }' ``` Expired keys are `401` and cannot be revived. ## Attribution [Section titled “Attribution”](#attribution) Everything a key does is attributed to it, derived server-side from the credential rather than from anything the caller sends. Staged changes name the key that proposed them, receipts name the key that made the read, and revoking a key leaves that history intact. ```json { "data": { "author": { "kind": "key", "id": "key_7f3a1c", "name": "support-agent" } } } ``` This is why a key per workload beats a shared key: it is the difference between “an agent wrote this” and “the support triage agent wrote this on 14 August.” ## Next [Section titled “Next”](#next) * **[Authentication](/docs/start/authentication/)** — the two key types side by side. * **[Receipts](/docs/govern/receipts/)** — proving what a key was shown. # One Brain per customer > Provisioning as a pure function of your tenant id — no ids to store, no reconciliation, no race. Give every customer their own Brain. Their context stays isolated from every other customer’s, and you never click a button to make one. ```ts const API = "https://api.nicia.ai"; async function brainFor(tenantId: string): Promise { const handle = `tenant:${tenantId}`; const response = await fetch(`${API}/v1/brains/${encodeURIComponent(handle)}`, { method: "PUT", headers: { authorization: `Bearer ${process.env.NICIA_KEY ?? ""}`, "content-type": "application/json", }, body: JSON.stringify({ name: tenantId, schema: "nicia-core" }), }); if (!response.ok) throw new Error(`provision ${tenantId}: ${response.status}`); return handle; // your handle is the address } ``` That is the whole provisioning story. Call it on every request if you like. ## Why there is nothing else to do [Section titled “Why there is nothing else to do”](#why-there-is-nothing-else-to-do) **Your handle is the address.** Every subsequent call is `/v1/brains/tenant:acme/...`, so there is no Nicia id to persist, no mapping table to keep in sync, and no migration if you change how you store tenants. **`PUT` is create-or-update.** First call `201`, every identical call after it `200`. There is no “does it exist yet” query to race against and no duplicate Brain to reconcile, so a retry storm is harmless. **A new Brain answers immediately.** A Brain this `PUT` creates starts in `open` mode unless the request states otherwise, so the first document you write is readable the moment it indexes. The web app creates Brains the same way — `open` by default — so most writes land and destructive changes still wait; switch to `"reviewed"` when you want agent claims to wait for approval. If you provision both ways, state the `mode` you mean rather than relying on either. There is no policy to configure before the Brain is useful, and no schema to author: naming [`nicia-core`](/docs/schemas/overview/#the-neutral-vocabulary-nicia-core) above gives every Brain in the fleet the same neutral vocabulary — people, organizations, topics, events, notes — so one query means the same thing against all of them. Drop the `schema` field entirely and each Brain gets [`nicia-base`](/docs/schemas/overview/#the-default-nicia-base) instead, which takes exactly the same writes but declares no typed fields — declaration governs and returns values later; non-slug `/query` filters still wait on a property index either way. ## Walking the fleet [Section titled “Walking the fleet”](#walking-the-fleet) Listing is keyset-paged. Cursors are opaque and stable under concurrent writes: ```bash curl "$API/v1/brains?limit=500" \ -H "authorization: Bearer $NICIA_KEY" ``` ```json { "data": { "brains": [ { "id": "brain_3a91c7", "generation": "6f1e4d6b-9f0c-4a21-8f3e-1f1a2b3c4d5e", "handle": "tenant:acme", "name": "Acme", "schema": "nicia-base", "mode": "open", "createdAt": "2026-08-14T09:12:04Z" } ], "nextCursor": "eyJrIjoi…" } } ``` ```ts async function* allBrains(): AsyncGenerator { let cursor: string | undefined; do { const url = new URL(`${API}/v1/brains`); url.searchParams.set("limit", "500"); if (cursor) url.searchParams.set("cursor", cursor); const { data } = await get(url); yield* data.brains; cursor = data.nextCursor; } while (cursor); } ``` ### Filtering by prefix [Section titled “Filtering by prefix”](#filtering-by-prefix) Planned — ships with fleet-scale listing Not shipped yet. `prefix` will narrow a listing to handles that start with it, for when your handles are structured (`tenant:acme`, `tenant:beta`, …): ```bash curl "$API/v1/brains?prefix=tenant:" \ -H "authorization: Bearer $NICIA_KEY" ``` ## Fan-out [Section titled “Fan-out”](#fan-out) There is no fleet-wide write on the Brain API — every Brain-scoped write is addressed to one handle. A change across many Brains is your loop over many Brains, which is deliberate: it keeps every write attributable to one subject and one decision, and it means a partial failure is a list of handles to retry rather than an ambiguous half-applied batch. The one exception lives on the org plane rather than the Brain API: rolling an additive schema change out to every Brain built on one of your organization’s schemas is a single call, `POST /v1/schemas/{slug}/rollout`, applied directly to Brains still tracking the baseline, for up to the first 200 Brains on the slug; a fleet larger than that is not fully reached. A Brain that has diverged, or whose review declarations the new baseline would lower, is reported `failed` rather than written — see [your organization’s own schema](/docs/schemas/overview/#your-organizations-own-schema). ```ts const failures: string[] = []; for await (const brain of allBrains()) { try { const response = await fetch(`${API}/v1/brains/${encodeURIComponent(brain.handle)}`, { method: "PATCH", headers: { authorization: `Bearer ${process.env.NICIA_KEY ?? ""}`, "content-type": "application/json", }, body: JSON.stringify({ mode: "reviewed" }), }); // `fetch` rejects on network failure only — a 400 or a 429 is a resolved // promise. Check the status or a rejected body fans out silently across // the whole fleet and the run reports zero failures having changed // nothing. if (!response.ok) failures.push(brain.handle); } catch { failures.push(brain.handle); // retry these, nothing else } } ``` Keep concurrency modest — 10 to 20 in flight is plenty — and honour `Retry-After` on `429`. ## Querying across the fleet [Section titled “Querying across the fleet”](#querying-across-the-fleet) Reads have one fleet-wide form too, on the same org plane: a typed record query across every Brain built on one of your organization’s schemas. It is the portfolio view over one Brain per customer, deal, or project — “every open diligence question, across every deal”: ```bash curl -X POST "$API/v1/schemas/deal-diligence/query" \ -H "authorization: Bearer $NICIA_KEY" \ -H "content-type: application/json" \ -d '{ "kind": "question", "where": { "status": "open" }, "consumerId": "portfolio-dashboard" }' ``` `kind` and `where` take exactly what [`POST /v1/brains/{handle}/query`](/docs/read/search/#query--rows) takes, but they are checked against the **organization** schema: a kind or field it does not declare is one `400`, before any Brain is read. Each Brain is then read the way its own `/query` would read it — and receipted on that Brain, under the `consumerId` you name — so the portfolio view leaves the same trail as a hundred per-Brain reads. Every record carries `brain`, the handle it came from, and every Brain the page reached is listed in `brains` with an `outcome`: * **`served`** — read and receipted; `receipt` is on the entry. * **`claimed`** — provisioned but never written, so it holds nothing. * **`not_queryable`** — this Brain does not declare the field the way the schema does, usually because it has not taken a [rollout](/docs/schemas/overview/#your-organizations-own-schema) yet. * **`index_not_ready`** — a field the query reads is being re-indexed after a schema change on this Brain. Retry. * **`failed`** — the read failed. `message` says why when the reason is yours to act on; an internal failure is logged on our side and says only that the Brain could not be read. One Brain’s refusal never fails the page. A page reads at most 20 Brains and up to `limit` records from each (at most 100); pass `nextCursor` until it is absent to walk the whole fleet — there is no ceiling on how many Brains a walk reaches. A Brain with more matches than `limit` carries its own `nextCursor`: continue that Brain through its own `/query` with the same filter. The walk visits provisioned-but-unwritten Brains first, then the rest, each in a stable order, so walking an unchanging fleet twice gives the same sequence. A Brain created mid-walk is picked up by the next walk. A Brain written for the first time mid-walk is never missed: it may appear twice, once as `claimed` and once read, but its records appear once. The query takes the management key, or the console session of a current member of the organization; a Brain key gets a `401`. ## Per-customer keys [Section titled “Per-customer keys”](#per-customer-keys) Mint the customer’s keys in the same provisioning step so the whole tenant is set up in one function: ```ts async function provision(tenantId: string) { const handle = await brainFor(tenantId); const { data } = await post(`${API}/v1/brains/${handle}/keys`, { name: `${tenantId} app`, capabilities: ["read"] }, { "idempotency-key": `app-key:${tenantId}` }); return { brain: handle, appSecret: data.secret }; // store the secret; it is shown once } ``` An `idempotency-key` derived from your tenant id means a retried provisioning run returns the same key rather than minting a second live secret — but the retry’s response carries `secret: null`, not the secret. Nicia stores only its hash, so a secret is only ever shown once, at the call that actually minted it: keep it from that first successful call. See [Idempotent minting](/docs/scale/brain-keys/#idempotent-minting) and [Idempotency](/docs/api/idempotency/). ## Isolation, honestly [Section titled “Isolation, honestly”](#isolation-honestly) Every Brain is **logically isolated**: its own vocabulary, its own mode, its own keys, and no read path between them. A key scoped to one Brain cannot reach another, and a management key names the Brain it is acting on in every call. Today all Brains in an organization share one physical store. Per-Brain physical placement is planned, not shipped. If you need physical separation between trust boundaries now, use a separate organization per boundary. ## Which Brains track your schema [Section titled “Which Brains track your schema”](#which-brains-track-your-schema) `GET /v1/brains/:handle/schema-lineage` answers the fleet question: which organization schema this Brain forked from, and whether that baseline has moved since. It needs a management key — a Brain key gets a `401`, not a `403`, because lineage exposes org-level schema facts a single-Brain credential isn’t scoped to, not a permission that credential merely lacks. ```bash curl "$API/v1/brains/tenant:acme/schema-lineage" \ -H "authorization: Bearer $NICIA_KEY" ``` The response is one of five statuses: * **`unrecorded`** — no lineage was ever stamped. This is not the same as “no drift”: a Brain provisioned the way the example at the top of this page does it, with a bare built-in slug like `nicia-core` (or no `schema` at all), never gets a stamp and always reads `unrecorded`. Lineage is only stamped when `schema` names an organization-registered slug or is an inline `{ kinds: [...] }` object. * **`independent`** — stamped, an inline schema with no `extends`. * **`in-sync`** — the recorded baseline still hashes to what this Brain was built on. * **`base-moved`** — the organization schema moved since this Brain forked from it, with the current baseline hash and, where computable, a summary of what it’s missing and what it declares differently (`changedKinds`, `changedFieldsByKind` — a field’s type, enum values, `multiplicity`, or `consequence`). * **`base-unresolvable`** — the recorded baseline no longer exists or no longer parses. ## Deleting [Section titled “Deleting”](#deleting) ```bash curl -X DELETE "$API/v1/brains/tenant:acme" \ -H "authorization: Bearer $NICIA_KEY" ``` Deletion is tombstoned: the handle stays reserved, so a re-running script gets a `409 conflict` rather than a silently empty Brain where the retired one used to be. Reviving it is explicit: ```bash curl -X PUT "$API/v1/brains/tenant:acme" \ -H "authorization: Bearer $NICIA_KEY" \ -H "content-type: application/json" \ -d '{ "name": "Acme", "revive": true }' ``` Receipts are retained, but they are read through the Brain’s handle, so a deleted Brain’s receipts answer `404` until it is revived. To keep an answer you gave a customer last quarter explainable after they churn, take an [export](/docs/team/tend-it/) first: it carries `receipts.json`. ## Limits [Section titled “Limits”](#limits) ### Brain count [Section titled “Brain count”](#brain-count) The cap is per organization and depends on plan — 10 on free, 50 on team, 500 on scale, with 500 as the platform ceiling regardless of plan. Hitting it returns `429` with a message naming the limit: a guardrail against a runaway loop minting unique Brains, not a ceiling the product wants you bumping into. If you’re planning a fleet-scale rollout on the free tier, talk to us first. **Until you verify your email, the cap is 2** — narrower than any plan, and applied on top of whichever plan resolves. A fresh sign-up wiring this loop up for the first time therefore gets `429` on its third Brain, which is a verification step rather than a quota to upgrade past. Verify the address on the account, and the plan’s own limit applies. ### Request rate [Section titled “Request rate”](#request-rate) Generous, and `429` carries `Retry-After`. Provisioning on every request is an expected pattern, not an abusive one. See [Errors](/docs/api/errors/). ## Next [Section titled “Next”](#next) * **[Handing out keys](/docs/scale/brain-keys/)** — giving a customer access to their own Brain. * **[Review](/docs/govern/review/)** — turning on approval per Brain. # Custom schemas > Declare your own vocabulary when you create the Brain, and keep every key you did not declare. 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`](/docs/schemas/overview/#the-default-nicia-base) and write records without a kind — no data is lost while you decide. What a schema does and does not decide A declared field governs its type and is returned retroactively: it applies to values already written, not only to the next ones. A schema never decides what you may store — undeclared keys are always accepted and always round-trip. The only things validated are a declared field’s own type and a declared link’s targets. Every declared field is filterable in `/query`; an undeclared one is returned but not queryable. Which fields a Brain has is settled on the call that creates it. See [when a schema is chosen](/docs/schemas/overview/#when-a-schema-is-chosen) for what that means for a Brain you already have. ## Declaring one [Section titled “Declaring one”](#declaring-one) ```bash 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”](#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](/docs/schemas/overview/#dates-and-instants) covers the accepted formats and `notifyBefore` reminders. ### Descriptions [Section titled “Descriptions”](#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](/docs/write/snapshots/) 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](/docs/tutorials/onboarding/) is that pipeline end to end. ## Keep it small [Section titled “Keep it small”](#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”](#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. ```bash curl "$API/v1/brains/tenant:acme/schema/candidates" \ -H "authorization: Bearer $NICIA_KEY" ``` ```json { "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: ```bash 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”](#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. ```bash 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.json ``` `schema-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”](#review-declarations) Two field declarations decide what a person is asked about, and both travel in the contract — `PATCH /schema` and an [organization schema](/docs/schemas/overview/#your-organizations-own-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](/docs/write/snapshots/) 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 `consequence` or `notifyBefore` 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. 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](/docs/schemas/overview/#when-a-declaration-changes). ## Views [Section titled “Views”](#views) 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](/docs/write/records/#who-said-a-value); 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`](#evolving): ```json { "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: ```bash curl "$API/v1/brains/tenant:acme/views/finance/records/acme" \ -H "authorization: Bearer $NICIA_KEY" ``` ```json { "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`](/docs/write/records/#reading-a-record-back) 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 `write` may add a view. Changing or removing one needs `admin`, 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”](#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. ```bash # 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](/docs/schemas/overview/#your-organizations-own-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. ## Next [Section titled “Next”](#next) * **[Schemas](/docs/schemas/overview/)** — the tiers and the default. * **[Records](/docs/write/records/)** — writing against your vocabulary. # Schemas > Start with none, keep the default, pick the neutral vocabulary, adopt a pack, or declare your own — chosen when the Brain is created. 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, return, and filter. It never decides what you can store — nothing you send is ever dropped. Every declared field is filterable in [`/query`](/docs/read/search/#query--rows); an undeclared one is stored and returned but not queryable. 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”](#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/{recordId}` with no `kind` | You have ids and fields but no entity model yet. | | **Named schema** | `"schema": ""` | 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. ## When a schema is chosen [Section titled “When a schema is chosen”](#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 submit one governed schema change that the Brain’s admission policy decides — an additive change lands, anything it holds waits in Review; 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](#your-organizations-own-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?”](#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](#your-organizations-own-schema)** — register it once, name its slug per Brain | | A domain model that mostly works | **[Custom](/docs/schemas/custom/)** — declare what you need governed and returned today | ## The default: `nicia-base` [Section titled “The default: nicia-base”](#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` filters by kind and `slug` only. If you already know the fields you want governed, returned, and filtered, say so on the create call — [`nicia-core`](#the-neutral-vocabulary-nicia-core) or a [custom schema](/docs/schemas/custom/). Every value you send stores and round-trips either way. | 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. ```bash 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”](#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. ```json { "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](/docs/schemas/custom/#declaring-one) that omits `extends`. ## The neutral vocabulary: `nicia-core` [Section titled “The neutral vocabulary: nicia-core”](#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: ```bash 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”](#your-organizations-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: ```bash 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, and takes the declared contract itself — the same `DeclaredSchemaContract` shape `GET /v1/brains/{handle}/schema` returns: ```bash curl -X POST "$API/v1/schemas" \ -H "authorization: Bearer $NICIA_KEY" \ -H "content-type: application/json" \ -d '{ "contract": { "slug": "customer-brain", "name": "Customer brain", "description": "What our agents know about one customer.", "nodes": [{ "kind": "product", "identityField": "name", "fields": [ { "name": "name", "type": "string" }, { "name": "price_usd", "type": "number", "multiplicity": "single", "consequence": "review" } ] }], "edges": [] } }' ``` It is registered exactly as sent — each field’s `multiplicity` and `consequence` included ([review declarations](/docs/schemas/custom/#review-declarations)) — and `GET /v1/schemas/{slug}` returns it. The platform’s own `definition` and `statement` kinds are not part of it: every Brain carries them. (The console’s schema editor commits the same endpoint with its own proposal body, which cannot set review declarations.) A registration is your organization’s own. Another organization may register the same slug and the same kind names with different fields; neither sees the other’s schemas in `GET /v1/schemas`, a read of the slug, its versions, or its fleet, and each organization’s Brains carry that organization’s shape. The registry takes the management key (or a signed-in console session); a Brain key is refused. An organization holds at most 100 registered schemas: registering a new slug past that is refused with `409 conflict`, while the schemas it holds keep evolving. 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](/docs/schemas/custom/#declaring-one). **This is also the one way to declare a field on Brains that already exist.** Commit the same slug again with the field added — kinds, fields, links, and review declarations may be added or tightened; removing or reshaping a declared one is refused — then `POST /v1/schemas/{slug}/rollout`. Brains still tracking the slug have it applied directly, a changed review declaration included (`GET /v1/schemas/{slug}/brains` lists it under `changedFields`). Two things a rollout never does silently: it does not write Brains that have grown local kinds of their own (each is reported `offered`: the merged contract, your organization’s declarations plus the Brain’s local additions, waits in that Brain’s Review, and a repeated rollout returns the same pending offer), and it does not lower a Brain’s review — a contract that removes or loosens a review declaration the Brain holds is reported `failed` for that Brain, and the change goes through the Brain’s own [governed evolution](/docs/schemas/custom/#evolving) instead. Values already stored under the new field become governed, returned, and filterable, with no re-import — see [when a declaration changes](#when-a-declaration-changes). 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. The fleet is also what `POST /v1/schemas/{slug}/query` reads: one typed record query across every Brain on the slug — every open question across one Brain per deal, say — validated against the organization schema and receipted on each Brain it serves. See [querying across the fleet](/docs/scale/fleet/#querying-across-the-fleet). ## Packs [Section titled “Packs”](#packs) 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. Planned — Nicia maintains no domain pack yet Domain packs — CRM, support, research — are not shipped. The two sources a `"schema": "…"` slug resolves against are the built-ins, `nicia-base` and `nicia-core`, and [your own organization’s registered schemas](#your-organizations-own-schema); a maintained pack is neither, so there is none for you to name. Until packs ship, [declare the kinds you need](#custom): a custom schema is the same shape a pack would install, authored by you. The `statement` kind — what one source reported about a subject — is not a pack: it and its links (`statement_subject`, `statement_object`, `derived_from_source`, `supersedes_statement`, `contradicts_statement`) are on every Brain’s schema, whatever the Brain declared, so [statements](/docs/write/statements/) and a snapshot’s recorded disagreements work on any Brain. A schema that declares its own `statement` gets the platform’s. The Standing Answers kinds (`standing_question`, `standing_answer`) exist as a pack in the platform, and 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”](#custom) When you know your ontology, declare it inline on the create call: ```bash 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": "datetime" }, { "name": "rollback", "type": "boolean" } ] } ], "links": [ { "name": "deployed_by", "from": ["deployment"], "to": ["person"] } ] } }' ``` Field types are `string`, `number`, `boolean`, `enum`, `string_list`, `date`, and `datetime`. 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. ### Dates and instants [Section titled “Dates and instants”](#dates-and-instants) A `date` is a calendar day, written `YYYY-MM-DD`; a `datetime` is an instant, written in ISO 8601 **with** its offset (`2026-12-01T09:00:00+02:00`) and stored and returned in UTC (`2026-12-01T07:00:00.000Z`). A wall-clock time with no offset names no instant and is refused rather than guessed. Every write path holds a value to its type — a record write, a batch, a [statement](/docs/write/statements/) whose predicate names the field, and a source snapshot’s extraction — so a `date` of `2026-02-30` or `12/01/2026` is refused, and what is stored is what `/query` compares. A `date` or `datetime` field may declare `notifyBefore`: ISO 8601 durations before the value at which the Brain emits a [`date.reached` event](/docs/govern/events/#date-reached) — a renewal nudge, a deadline watch: ```json { "name": "contract_end", "type": "date", "notifyBefore": ["P30D", "P7D", "P0D"] } ``` `P0D` is the date itself. Only `date` and `datetime` fields take it; at most eight distinct offsets. See [Custom schemas](/docs/schemas/custom/) 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”](#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: ```bash curl "$API/v1/brains/tenant:acme/schema/candidates" \ -H "authorization: Bearer $NICIA_KEY" ``` ```json { "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”](#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. ```bash 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` and the complete evolved `schema` returned by `GET /schema`. See [Custom schemas](/docs/schemas/custom/#evolving) 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. ### When a declaration changes [Section titled “When a declaration changes”](#when-a-declaration-changes) Declaring a field on a kind that already has records — or widening an enum, or adding a `notifyBefore` — rebuilds that field’s index from the records the Brain holds. A small Brain is rebuilt before the schema call returns; a larger one finishes in the background within minutes. While a field’s index is being rebuilt, a `/query` predicate on it returns `400 index_not_ready` naming the field; every other field stays queryable. A value the new declaration cannot hold stays stored and returned, and is simply not matched. ## Next [Section titled “Next”](#next) * **[Records](/docs/write/records/)** — writing against the vocabulary. * **[Custom schemas](/docs/schemas/custom/)** — declaring and evolving your own. * **[Search and queries](/docs/read/search/)** — what typed fields buy you at read time. # Add a Brain to your product > A runnable activation path for SaaS and agent products. Agents don’t need memory. They need a Brain. This guide is for an existing SaaS or agent product. You already have stable tenant ids, canonical structured data, source documents, and one or more agents. Add a Brain without replacing your system of record. ## 1. Choose an immutable address [Section titled “1. Choose an immutable address”](#1-choose-an-immutable-address) Derive the Brain handle once from your stable, non-human tenant id: ```text tenant:${stableTenantId} ``` Keep the display name editable. Do not use a mutable company slug as the address. In the examples below, `tenant:acme_123` is the stable handle and `$API` is `https://api.nicia.ai`. ## 2. Provision idempotently [Section titled “2. Provision idempotently”](#2-provision-idempotently) Send this from your provisioning backend with an organization key (`nsk_…`, from **Settings → API keys**; see [Authentication](/docs/start/authentication/)). The key carries its organization, so there is no organization id to send. `PUT` on the same handle is safe to repeat: the first response is `201`, later reconciles are `200`. The `schema` is applied on that first `PUT` only — a later `PUT` reconciles `name`, `purpose`, and `mode`, while governed evolution uses `PATCH /v1/brains/{handle}/schema` (see [Schemas](/docs/schemas/overview/#when-a-schema-is-chosen)). Changing which schema this call NAMES is a different thing, and it is a hard `400`, not a silent skip: a Brain’s schema is evolved in place, never swapped by re-sending `schema`. An inline schema is filed under a slug derived from the handle, so editing the kinds and fields inside it keeps the same slug and still answers `200` — but switching between an inline schema and a named one, or between two named ones, is refused. Know the blast radius before you edit that field in a provisioning template every tenant runs: new tenants succeed on the create arm while every existing tenant’s idempotent `PUT` starts failing. ```bash API="https://api.nicia.ai" HANDLE="tenant:acme_123" curl -X PUT "$API/v1/brains/$HANDLE" \ -H "authorization: Bearer $MANAGEMENT_KEY" \ -H "content-type: application/json" \ -d '{"name":"Acme","purpose":"Customer account context","mode":"reviewed","schema":{"extends":"nicia-core","kinds":[{"name":"company","description":"A customer account in the product.","fields":[{"name":"plan","type":"string","description":"Current subscription plan."},{"name":"ownerEmail","type":"string","description":"The account owner email."}]}]}}' ``` ```json { "name": "Acme", "purpose": "Customer account context", "mode": "reviewed", "schema": { "extends": "nicia-core", "kinds": [ { "name": "company", "fields": [ { "name": "plan", "type": "string" }, { "name": "ownerEmail", "type": "string" } ] } ] } } ``` The handle is the stable customer-owned address. Store the returned opaque Brain id only when an older id-addressed integration needs it. ## 3. Mint two scoped Brain keys [Section titled “3. Mint two scoped Brain keys”](#3-mint-two-scoped-brain-keys) Use the management credential only in this backend. The management-key issuance flow is interactive; once you have that credential, mint two Brain-scoped keys and keep each one in its narrow runtime: * **Sync service:** `write` + `review` (add `read` only when the sync needs to inspect accepted state). * **Product agent:** `read` + `write`, never `review`. The Brain’s `mode` decides whether a write is accepted or staged. A key cannot choose a direct-write bypass. ```json { "name": "Acme sync service", "capabilities": ["write", "review"], "client": "api" } ``` ```bash curl -X POST "$API/v1/brains/$HANDLE/keys" \ -H "authorization: Bearer $MANAGEMENT_KEY" \ -H "content-type: application/json" \ -H "idempotency-key: acme-123-sync-key-v1" \ -d '{"name":"Acme sync service","capabilities":["write","review"],"client":"api"}' ``` The secret is returned once. Store it in your secret manager immediately; replaying the same idempotency key returns the key record with a null secret. ## 4. Sync application-owned records [Section titled “4. Sync application-owned records”](#4-sync-application-owned-records) Use deterministic record ids derived at your edge, and keep `Idempotency-Key` stable for non-addressed batch writes. A sync that finds nothing to send makes no call at all — `/batch` requires at least one record and rejects an empty `records` array with a `400`. Missing rows never mean deletion. For one record, use an addressed merge. The write response tells you the address, the opaque record `token` to send back as `If-Match`, and the admission change; it does not read accepted values back. ```bash curl -X PATCH "$API/v1/brains/$HANDLE/records/customer-acme-123" \ -H "authorization: Bearer $SYNC_KEY" \ -H "content-type: application/json" \ -d '{"kind":"company","label":"Acme","fields":{"plan":"pro","ownerEmail":"ops@acme.example"}}' ``` For a group of records, send one atomic batch and retry with the same idempotency key: ```json { "records": [ { "id": "customer-acme-123", "kind": "company", "fields": { "plan": "pro", "ownerEmail": "ops@acme.example" } } ], "intent": "nightly customer sync" } ``` ```bash curl -X POST "$API/v1/brains/$HANDLE/batch" \ -H "authorization: Bearer $SYNC_KEY" \ -H "content-type: application/json" \ -H "Idempotency-Key: sync-acme-123-2026-08-23" \ -d '{"records":[{"id":"customer-acme-123","kind":"company","fields":{"plan":"pro","ownerEmail":"ops@acme.example"}}],"intent":"nightly customer sync"}' ``` Because this Brain is reviewed, the change is staged. Approve only the `change` id returned by this write; do not scan the queue and approve everything: ```bash curl -X POST "$API/v1/brains/$HANDLE/changes/$SYNC_CHANGE_ID/lifecycle/approve" \ -H "authorization: Bearer $SYNC_KEY" curl -X POST "$API/v1/brains/$HANDLE/changes/$SYNC_CHANGE_ID/lifecycle/publish" \ -H "authorization: Bearer $SYNC_KEY" \ -H "content-type: application/json" \ -H "idempotency-key: publish-$SYNC_CHANGE_ID" \ -d '{}' ``` ## 5. Add source text at a deterministic address [Section titled “5. Add source text at a deterministic address”](#5-add-source-text-at-a-deterministic-address) Documents provide cited context independently of custom-kind extraction. Give each document a stable id and send non-empty text. Set `extract: false` when the text should be indexed without extracting records. ```json { "id": "customer-acme-123-handbook", "title": "Acme account handbook", "text": "Acme renews annually. Support escalation begins with the account owner.", "metadata": { "source": "crm" }, "extract": false } ``` ```bash curl -X POST "$API/v1/brains/$HANDLE/documents" \ -H "authorization: Bearer $SYNC_KEY" \ -H "content-type: application/json" \ -d '{"id":"customer-acme-123-handbook","title":"Acme account handbook","text":"Acme renews annually. Support escalation begins with the account owner.","metadata":{"source":"crm"},"extract":false}' ``` An empty document fails validation and creates nothing. Check the document status if indexing is queued or processing; a missing source in one sync is never interpreted as deletion. ## 6. Query typed state and read cited context [Section titled “6. Query typed state and read cited context”](#6-query-typed-state-and-read-cited-context) A declared field applies retroactively because undeclared values were never dropped: its values become governed, returned, and filterable once it is declared. `where` takes every declared field — equality, `ne`, `in`, `has` on a list, and ranges on numbers, dates, and instants; a predicate on an undeclared field is a `400` naming it. Use `POST /query` for typed filtering and exact slug resolution, and `POST /context` for the prompt-shaped read. ```json { "kind": "company", "where": { "slug": "customer-acme-123" }, "limit": 20, "consumerId": "acme-agent" } ``` ```bash curl -X POST "$API/v1/brains/$HANDLE/query" \ -H "authorization: Bearer $AGENT_KEY" \ -H "content-type: application/json" \ -d '{"kind":"company","where":{"slug":"customer-acme-123"},"limit":20,"consumerId":"acme-agent"}' ``` ```json { "prompt": "What should the support agent know before responding to Acme?", "maxTokens": 2048, "consumerId": "acme-agent", "format": "text" } ``` ```bash curl -X POST "$API/v1/brains/$HANDLE/context" \ -H "authorization: Bearer $AGENT_KEY" \ -H "content-type: application/json" \ -d '{"prompt":"What should the support agent know before responding to Acme?","maxTokens":2048,"consumerId":"acme-agent","format":"text"}' ``` Pass the response’s `text`, `citations`, and `receipt` through to your product. `warnings[]` explains processing, access scope, truncation, or staged material; it does not report that two sources disagree. ## 7. Propose an agent change, then settle it [Section titled “7. Propose an agent change, then settle it”](#7-propose-an-agent-change-then-settle-it) Give the agent key `read` + `write`, never `review`. Have the agent submit an addressed merge or batch write. On a reviewed Brain the response is staged, accepted state remains unchanged, and the sync service or a human later approves or rejects that one change. Verify the boundary explicitly: an agent-key request to `/changes/{changeId}/lifecycle/approve` must return `403 forbidden`. A reviewer settles the exact returned id: ```bash curl -X POST "$API/v1/brains/$HANDLE/changes/$AGENT_CHANGE_ID/lifecycle/approve" \ -H "authorization: Bearer $SYNC_KEY" curl -X POST "$API/v1/brains/$HANDLE/changes/$AGENT_CHANGE_ID/lifecycle/publish" \ -H "authorization: Bearer $SYNC_KEY" \ -H "content-type: application/json" \ -H "idempotency-key: publish-$AGENT_CHANGE_ID" \ -d '{}' ``` ## Retries, failures, and cleanup [Section titled “Retries, failures, and cleanup”](#retries-failures-and-cleanup) * Retry only `429` and `5xx`, honor `Retry-After`, and cap attempts. * Repeat a timed-out addressed write at the same address; repeat a batch with the same `Idempotency-Key`. * Stop on other `4xx` responses and report the parsed error plus request id. * Never continue after a malformed response. * Revoke every test key in a `finally` path, even when Brain cleanup fails. * Delete a test Brain explicitly; deletion is a lifecycle action, never an interpretation of missing sync data. Deleting tombstones the handle, which stays reserved — a later `PUT` on the same handle is a `409` until it carries `"revive": true`. See [One Brain per customer](/docs/scale/fleet/). Read [errors](/docs/api/errors/), [idempotency](/docs/api/idempotency/), [authentication](/docs/start/authentication/), and the [OpenAPI reference](/docs/reference/) for the complete wire contract. # Authentication > One credential concept, two scopes — a management key for your backend and a Brain key for everyone else. There is one HTTP scheme: `authorization: Bearer`. What the credential reaches depends on its prefix, not the header it travels in. | Key | Reaches | Held by | | --------------------------- | ---------------------------------------------------------------------------- | ------------------------------------------- | | **Management key** (`nsk_`) | Every Brain in one organization, plus creating Brains and minting Brain keys | Your backend | | **Brain key** (`nbk_`) | One Brain, with only the capabilities you granted | An app, a department, an end user, an agent | A Brain key is bound to one Brain, so it never needs to be told which one. A management key is bound to one organization the moment it is minted, so it doesn’t either. Key and handle are different things A **key** is always a secret you send in a header. A Brain’s public address is its **handle** — `tenant:acme` — and it appears in URLs, logs, and receipts. If a value is safe to print, it is a handle. ## Management keys [Section titled “Management keys”](#management-keys) Sign in at [app.nicia.ai](https://app.nicia.ai), open **Settings → API keys**, and mint one under **Organization keys**. The secret is shown once and stored only as a hash — copy it now; a lost key is replaced, never recovered. ```bash curl https://api.nicia.ai/v1/brains \ -H "authorization: Bearer $NICIA_KEY" ``` That’s it — no second id to send. The key is bound to the organization it was minted from, so every call it makes reaches that organization and no other. ### Minting one over HTTP [Section titled “Minting one over HTTP”](#minting-one-over-http) `POST /v1/keys` mints a key, `GET /v1/keys` lists them, and `DELETE /v1/keys/{id}` revokes one — all three from a **signed-in browser session**, never from a key, in either header: a valid management key is refused with `403 forbidden` here, because a credential that can mint its own successor would outlive its own revocation. That is why the console above is the first step, and this is the automation path afterwards. Account creation is open — no invitation code — and a script can do it. Two things decide whether it works, and both are easy to get backwards: **Send an `Origin` header — and send the app’s own origin.** This is the opposite of what the shape of the thing suggests, so it is worth being precise about. Origin validation is CSRF protection and engages for anything that looks browser-originated. Four things trigger it, not three: a `cookie`, an `Origin`, a `Referer` — and **any `Sec-Fetch-Site`, `Sec-Fetch-Mode` or `Sec-Fetch-Dest` header**. That fourth one is the trap, because you do not write it. **Node’s built-in `fetch` sends `Sec-Fetch-Mode: cors` on every request**, including a plain server-to-server POST, so validation engages, finds no origin, and answers `403 MISSING_OR_NULL_ORIGIN`. `curl` sends none of those headers, which is why the same call succeeds from a shell and fails from a script. If you are debugging this, that difference is the whole story. So send the app’s own origin on the sign-up call, and it validates against the trusted list and passes. In production that is one header: ```plaintext origin: https://app.nicia.ai ``` Setting `Origin` to some other host does not help — validation is against the trusted list, not against whether the header is present. **Use the cookie sign-up already returned.** The sign-up endpoint under `/api/auth` answers with a session, so a script never needs to call sign-in — sign-in is the one that requires a verified email address. Signing up and then signing in is the long way round, and it is the way that fails. (Those endpoints belong to the auth layer rather than to this API’s contracts, so they are not in the OpenAPI reference.) An account created this way starts **unverified**, which caps what it may consume until the address is confirmed. See [Billing](/docs/scale/fleet/) for the limits. ```bash curl -X POST https://api.nicia.ai/v1/keys \ -H "cookie: $NICIA_SESSION" \ -H "content-type: application/json" \ -d '{ "name": "provisioning service" }' ``` ```json { "data": { "key": { "id": "key_2d90ff", "name": "provisioning service", "organizationId": "org_4471", "status": "active", "createdAt": "2026-08-14T09:15:41Z" }, "secret": "nsk_…" } } ``` The secret is returned exactly once. `expiresAt`, if you send one, must be between 1 and 365 days out. Every call mints a new key — this endpoint is not idempotent. A write made with a management key is the write of the person who minted it: it lands as a person’s write does under the Brain’s [admission level](/docs/govern/review/), and it is recorded against the key’s id, so it stays distinguishable from that person’s console writes and from their other keys. A management key authorizes only while the person who minted it is still a member of that organization: remove them and their keys stop on the next request, everywhere. Mint from an account the organization keeps if the credential has to outlive any one employee, and revoke explicitly rather than relying on an offboarding checklist. ```bash curl https://api.nicia.ai/v1/keys \ -H "cookie: $NICIA_SESSION" curl -X DELETE https://api.nicia.ai/v1/keys/key_2d90ff \ -H "cookie: $NICIA_SESSION" ``` Listing includes revoked keys, so the audit trail survives, and never returns a secret. Revoking is idempotent, and takes effect on the next request everywhere. It disables the row rather than deleting it, so it stays listed — the console’s confirm dialog says so before you click through. Use a management key when your own backend is the only thing holding it: a provisioning service, a nightly sync job, a server-side agent that works across many customers. It is the right key for the [Quickstart](/docs/start/quickstart/) because there is nothing to set up before you can use it. No other credential works `Authorization: Bearer` is the only scheme `/v1/*` accepts. Personal `app_` keys and the `x-api-key` header are refused outright — `x-api-key` answers `401 unauthorized` before any credential is even looked up. Mint an organization key from **Settings → API keys** or `POST /v1/keys` instead; see the [changelog](/docs/changelog/) for when that landed. An organization key is bound to the organization it was minted in, so no call needs an organization id. A key minted from the console uses the organization you are signed in to. ## Brain keys [Section titled “Brain keys”](#brain-keys) A Brain key reaches exactly one Brain, with exactly the capabilities you list: ```bash curl -X POST https://api.nicia.ai/v1/brains/tenant:acme/keys \ -H "authorization: Bearer $NICIA_KEY" \ -H "content-type: application/json" \ -d '{ "name": "Acme web app", "capabilities": ["read"] }' ``` ```json { "data": { "key": { "id": "key_7f3a1c", "name": "Acme web app", "capabilities": ["read"], "status": "active", "createdAt": "2026-08-14T09:15:41Z" }, "secret": "nbk_…" } } ``` Use one whenever the holder is not you: a customer-facing app, an end user’s Claude Code, an agent you did not write, a department inside a company whose IT team provisioned the Brain. A leaked Brain key reaches one customer’s Brain with one capability set, and revoking it touches nothing else. Minting, rotating, and revoking a Brain key are management-key operations — the Brain key itself is what you hand to the holder. ### Capabilities [Section titled “Capabilities”](#capabilities) | Capability | Grants | | ---------- | ------------------------------------------------------------------------------------------------------------------------------------------------------ | | `read` | `/context`, `/search`, `/query`, reading records and the receipts it committed, exporting the Brain, and its own name, purpose, and mode | | `write` | `/documents`, record writes, `/batch`, and proposing a schema change (`PATCH /schema`, `/schema/promote`) | | `review` | Listing and settling changes, and who was served a record or document | | `admin` | `PATCH /v1/brains/{handle}` — the review switch — the schema (read and evolve), and, beside `read`, every key’s receipts; **not** the Brain’s contents | `admin` deliberately grants no access to knowledge: administering a Brain is not a reason to read what it knows. It is the capability that turns review on and off, so a service that flips Brains between `open` and `reviewed` needs an `admin` key and nothing more. That separation is only worth anything if it holds for bulk egress too, so `admin` carries neither export nor webhook control: an `admin` key cannot start a bulk export or point a webhook at change payloads. Export is served on `read` — `POST /v1/brains/{handle}/exports` — because it carries the whole Brain out and `read` is the capability that already reads it; a key holding only `admin`, `write`, or `review` is refused. Webhooks have no Brain-key endpoint yet, and when they arrive they get their own capability rather than being folded into `admin`, and the [changelog](/docs/changelog/) will say so. Schema evolution goes the other way: evolving a schema and promoting emerged structure are served on `write` or `admin`, and an `admin` key may read the schema it evolves (`GET /v1/brains/{handle}/schema` admits `read` or `admin`) — its kinds and declarations, never a record, document, or statement. `write` admits them because submitting a schema change is not deciding it: an agent that needs a kind the Brain lacks proposes it with the same key it writes records with, and the Brain’s `mode` decides whether it lands, exactly as it decides a record write. Both submit a governed schema change the Brain’s admission policy decides; applying it runs the canonical schema mutation and settles only after declared-vocabulary materialization verifies ready, and a field it declares becomes filterable in `/query` once its index is rebuilt. The [API reference](/docs/reference/) is the list of what each capability reaches. Whether writes land or stage is not a capability at all: it is the Brain’s `mode`, set on the Brain itself. Five things are **management-key only**, and no Brain key grants them, at any capability: creating a Brain (`PUT /v1/brains/{handle}`), deleting one, listing the organization’s Brains, minting/rotating/revoking/listing a Brain’s keys, and reading a Brain’s schema lineage (`GET /v1/brains/{handle}/schema-lineage`). The first four are refused because a key that could mint its own successor would make revocation meaningless, and a Brain key cannot exist before its Brain does. Schema lineage is refused for a different reason: it reports which organization schema a Brain forked from and whether that baseline has moved — a fact about the organization’s schema registry, outside what a single-Brain credential is scoped to. All five answer `401 unauthorized` to a Brain key, not `403`. There is no `preset` parameter on minting a Brain key — send the `capabilities` array you want. The common shapes are `["read"]`, `["read", "write"]`, and `["read", "review"]`. ### Rotating and revoking [Section titled “Rotating and revoking”](#rotating-and-revoking) ```bash # Mint a replacement; the old secret keeps working for 60 more seconds curl -X POST https://api.nicia.ai/v1/brains/tenant:acme/keys/key_7f3a1c/rotate \ -H "authorization: Bearer $NICIA_KEY" curl -X DELETE https://api.nicia.ai/v1/brains/tenant:acme/keys/key_7f3a1c \ -H "authorization: Bearer $NICIA_KEY" ``` Rotation’s overlap window is **60 seconds**, not “until you revoke”: deploy the new secret inside it or the fleet still holding the old one starts failing on its own. Revocation is separate, and takes effect on the next request everywhere — it is the way to end a compromised key now rather than in a minute. ## Which key should I use? [Section titled “Which key should I use?”](#which-key-should-i-use) * **Provisioning a Brain per customer from your backend** → management key. * **Your server-side agent reading many customers’ Brains** → management key. * **Shipping a key inside an app, a browser, or an end user’s tool** → Brain key. * **Corporate IT creating a Brain per department and handing it over** → Brain key per department. If a key would ever leave a machine you control, it should be a Brain key. ## Errors [Section titled “Errors”](#errors) A missing or invalid key is `401 unauthorized`. A valid key without the required capability is `403 forbidden`, and the message names both the capability the operation needed and the ones your key holds. Too many requests is `429 rate_limited` with a `Retry-After` header. See [Errors](/docs/api/errors/). ## Next [Section titled “Next”](#next) * **[Handing out keys](/docs/scale/brain-keys/)** — patterns for fleets and end users. * **[Quickstart](/docs/start/quickstart/)** — a key to an answer in three calls. # Quickstart > From nothing to a grounded, cited answer in three HTTP calls. Agents don’t need memory. They need a Brain. This quickstart gets you a cited answer in three HTTP calls: create a Brain, put something in it, ask it a question. No schema to design, no policy to configure, no ids to write down. ## 1. Get a key [Section titled “1. Get a key”](#1-get-a-key) Sign in at [app.nicia.ai](https://app.nicia.ai), open **Settings → API keys**, and mint an **organization key** under “Organization keys”. The secret is shown once — copy it now. Getting this first key is interactive, and nothing on this site shortcuts it. Creating an account is open to anyone, but signing in needs a verified email address, and both endpoints refuse a request that did not come from the product frontend. Budget for one person in a browser, once — every call below is automatable afterwards. See [Authentication](/docs/start/authentication/#minting-one-over-http). ```bash export NICIA_KEY="…" export API="https://api.nicia.ai" ``` An organization key can do anything inside the organization you minted it from: create Brains, read and write them, and mint narrower keys to hand out later. That is why it is the right key for a quickstart and the wrong key to ship inside a client app. It is bound to that organization at mint time, so every call below sends it alone, in `authorization: Bearer` — there is no second id to copy or send. See [Authentication](/docs/start/authentication/). Every response is `{ "data": … }` on success and `{ "error": { "kind", "message" } }` on failure. See [Errors](/docs/api/errors/). ## 2. Create a Brain [Section titled “2. Create a Brain”](#2-create-a-brain) A Brain is addressed by a **handle** you choose: a customer id, an employee id, a project slug — anything stable that you already have. The handle is public and appears in every URL. A **key** is always a secret credential; the two are never the same thing. ```bash curl -X PUT "$API/v1/brains/tenant:acme" \ -H "authorization: Bearer $NICIA_KEY" \ -H "content-type: application/json" \ -d '{ "name": "Acme", "purpose": "Everything we know about Acme." }' ``` ```json { "data": { "brain": { "id": "brain_3a91c7", "generation": "6f1e4d6b-9f0c-4a21-8f3e-1f1a2b3c4d5e", "handle": "tenant:acme", "name": "Acme", "purpose": "Everything we know about Acme.", "schema": "nicia-base", "mode": "open", "createdAt": "2026-08-14T09:12:04.221Z", "updatedAt": "2026-08-14T09:12:04.221Z" } } } ``` `PUT` is create-or-update on **your** handle, so it is safe to call on every request, in every worker, after every crash. The first call returns `201`, every identical call after it returns `200`, and there is no “does it exist yet” query to race against. Three defaults worth knowing: * **`schema: "nicia-base"`** — a deterministic starter vocabulary (people, companies, deals, concepts, notes). You did not have to design anything. `nicia-core`, the domain-neutral vocabulary, is available by naming it explicitly on the create call — the initial schema is fixed on creation, and later changes go through governed schema evolution; see [Schemas](/docs/schemas/overview/)). * **`mode: "open"`** — most writes land immediately; destructive changes still wait for approval. Switch to `"reviewed"` when you want a human to approve what agents write. See [Review](/docs/govern/review/). * **The Brain is addressed by `tenant:acme` from here on.** Nicia’s own `id` comes back in the response and works in the same slot, but nothing on this site asks for it — every path below takes the handle. Store it only if you call one of the older id-addressed paths. `generation` beside it is the Brain’s lifecycle fence — opaque, re-minted by a revive or reprovision, and needed only by the generation-scoped writes on the older planes. ## 3. Put something in [Section titled “3. Put something in”](#3-put-something-in) Documents and structured records are peer on-ramps. Use whichever you have. ```bash # A document — text, markdown, a transcript. Text, not bytes: convert files first. curl -X POST "$API/v1/brains/tenant:acme/documents" \ -H "authorization: Bearer $NICIA_KEY" \ -H "content-type: application/json" \ -d '{ "id": "hubspot:deal:412", "text": "Renewal call 2026-03-14. Acme renewed at $120k for 24 months. Bob Smith (Head of Platform) pushed for SSO before Q3.", "metadata": { "source": "hubspot", "type": "call-note" } }' ``` ```json { "data": { "document": { "id": "hubspot:deal:412", "status": "queued" } } } ``` `id` is yours. Sending the same `id` again replaces that document, so a nightly sync is a loop of `POST`s and nothing else. If what you have is already structured, send it as a record instead — same Brain, no schema step: ```bash curl -X PATCH "$API/v1/brains/tenant:acme/records/bob-smith" \ -H "authorization: Bearer $NICIA_KEY" \ -H "content-type: application/json" \ -d '{ "fields": { "name": "Bob Smith", "role": "Head of Platform" } }' ``` See [Documents](/docs/write/documents/) and [Records](/docs/write/records/). ## 4. Ask it something [Section titled “4. Ask it something”](#4-ask-it-something) ```bash curl -X POST "$API/v1/brains/tenant:acme/context" \ -H "authorization: Bearer $NICIA_KEY" \ -H "content-type: application/json" \ -d '{ "prompt": "What should I know before the renewal call?", "maxTokens": 2000 }' ``` ```json { "data": { "text": "Renewal call 2026-03-14. Acme renewed at $120k for 24 months. Bob Smith (Head of Platform) pushed for SSO before Q3.\n\nbob-smith\nname: Bob Smith\nrole: Head of Platform", "citations": [ { "source": "hubspot:deal:412", "slug": "brain-evidence-0dd3e2449851d9ba41924c42bd7a61375a8b78050bfe2ca7192eb918d626236a", "quote": "Renewal call 2026-03-14. Acme renewed at $120k for 24 months. Bob Smith (Head of Platform) pushed for SSO before Q3.", "recordedAt": "2026-08-14T09:13:10.882Z" }, { "slug": "bob-smith", "quote": "bob-smith\nname: Bob Smith\nrole: Head of Platform", "recordedAt": "2026-08-14T09:13:44.117Z" } ], "staged": 0, "warnings": [], "receipt": "rcp_019ffefe", "truncated": false } } ``` Paste `text` straight into your prompt. Show `citations` to your user. Keep `receipt` in your logs — it is how you reconstruct months later exactly what the model was shown. See [Receipts](/docs/govern/receipts/). `text` is one block per source, blank-line separated: a document contributes its prose — plus a name line and its frontmatter keys, when it carries any — and a record its label, its kind, and its values. A record with no `label` is headed by its id, which is why Bob’s block starts `bob-smith`. Each block is the matching `citations[].quote`, verbatim — so `text` is never more than the sum of what you can show a user, and never less. See [what `text` is](/docs/read/context/#what-text-is). `staged` is the count of changes waiting for review. It is usually `0` on an `open` Brain, whose writes are admitted as they arrive; a non-zero value tells you an empty answer means “waiting”, not “nothing there”. See [Context](/docs/read/context/#an-empty-answer-explains-itself). That is the cited read. The next ten minutes are the loop that makes it defensible: correct what was wrong, see the receipt go stale, and notice when two sources disagree — on `/context`, not on a second endpoint. ## 5. Correct, see the receipt go stale, notice disagreement [Section titled “5. Correct, see the receipt go stale, notice disagreement”](#5-correct-see-the-receipt-go-stale-notice-disagreement) The receipt from step 4 is `rcp_019ffefe`. Rewrite the document — same `id`, new text — and the next read is current. The old receipt is not. ```bash curl -X POST "$API/v1/brains/tenant:acme/documents" \ -H "authorization: Bearer $NICIA_KEY" \ -H "content-type: application/json" \ -d '{ "id": "hubspot:deal:412", "text": "Amendment signed 2026-08-01. Acme renewed at $145k for 24 months. Bob Smith (Head of Platform) pushed for SSO before Q3." }' ``` ```bash curl "$API/v1/brains/tenant:acme/receipts/rcp_019ffefe/now" \ -H "authorization: Bearer $NICIA_KEY" ``` ```json { "data": { "changed": [ { "source": "hubspot:deal:412", "shownVersion": 2, "currentVersion": 3, "status": "superseded" } ], "unchanged": 1, "deleted": 0 } } ``` `$120k` was right when it was said. It is wrong now — that is what `receiptNow` just told you, per consumer, without a second endpoint. A second source can disagree without anyone correcting anything. Write two statements about the same subject and predicate — each with its own `sourceKey` — and `/context` names the disagreement inline. There is no `conflicts[]` field; the signal is `warnings[]` kind `contested` plus per-item `status`. `source` on a statement is a **record id** — kebab, same rule as every other record — not a document id with colons. Create the source records first: ```bash curl -X PATCH "$API/v1/brains/tenant:acme/records/hubspot-deal-412" \ -H "authorization: Bearer $NICIA_KEY" \ -H "content-type: application/json" \ -d '{ "fields": { "name": "HubSpot deal 412" } }' curl -X PATCH "$API/v1/brains/tenant:acme/records/netsuite-renewal" \ -H "authorization: Bearer $NICIA_KEY" \ -H "content-type: application/json" \ -d '{ "fields": { "name": "NetSuite renewal record" } }' curl -X PATCH "$API/v1/brains/tenant:acme/records/acme" \ -H "authorization: Bearer $NICIA_KEY" \ -H "content-type: application/json" \ -d '{ "fields": { "name": "Acme" } }' ``` ```bash curl -X POST "$API/v1/brains/tenant:acme/statements" \ -H "authorization: Bearer $NICIA_KEY" \ -H "content-type: application/json" \ -H "idempotency-key: stmt-salesforce-arr" \ -d '{ "subject": "acme", "predicate": "contract_value_usd", "value": 120000, "sourceKey": "hubspot-deal-412" }' curl -X POST "$API/v1/brains/tenant:acme/statements" \ -H "authorization: Bearer $NICIA_KEY" \ -H "content-type: application/json" \ -H "idempotency-key: stmt-netsuite-arr" \ -d '{ "subject": "acme", "predicate": "contract_value_usd", "value": 145000, "sourceKey": "netsuite-renewal" }' curl -X POST "$API/v1/brains/tenant:acme/context" \ -H "authorization: Bearer $NICIA_KEY" \ -H "content-type: application/json" \ -d '{ "prompt": "What is Acme's contract value?", "maxTokens": 2000, "format": "items" }' ``` ```json { "data": { "text": "", "citations": [], "items": [ { "kind": "record", "id": "acme", "fields": { "contract_value_usd": 120000 }, "status": "contested" } ], "staged": 0, "warnings": [ { "kind": "contested", "subject": "acme", "predicate": "contract_value_usd", "count": 2 } ], "receipt": "rcp_contested", "truncated": false } } ``` `sourceKey` may identify a stable row or field inside the authenticated producer’s source. The source identity always comes from the authenticated key, never the body. See [Statements](/docs/write/statements/). Everything below is the same three calls in different proportions. ## The same three calls at three scales [Section titled “The same three calls at three scales”](#the-same-three-calls-at-three-scales) ### One Brain, many clients [Section titled “One Brain, many clients”](#one-brain-many-clients) Keep one Brain and give each tool its own key, so revocation and attribution stay precise: ```bash # A read-only key for your web app curl -X POST "$API/v1/brains/tenant:acme/keys" \ -H "authorization: Bearer $NICIA_KEY" \ -H "content-type: application/json" \ -d '{ "name": "web app", "capabilities": ["read"] }' ``` ```json { "data": { "key": { "id": "key_7f3a1c", "name": "web app", "capabilities": ["read"], "status": "active", "createdAt": "2026-08-14T09:15:41Z" }, "secret": "nbk_9f2c…" } } ``` `secret` is shown once. See [Handing out keys](/docs/scale/brain-keys/). ### A team [Section titled “A team”](#a-team) One Brain per project or account, all on the same schema so a slug (or `/context` prompt) written once means the same thing against every one of them. `nicia-core` is the neutral vocabulary — people, organizations, topics, events, notes — which is enough for shared kinds and returned fields without anyone authoring an ontology first. Non-slug `/query` filters still wait on a property index: ```bash curl -X PUT "$API/v1/brains/project:atlas" \ -H "authorization: Bearer $NICIA_KEY" \ -H "content-type: application/json" \ -d '{ "name": "Atlas", "schema": "nicia-core", "mode": "reviewed" }' ``` `mode: "reviewed"` is what turns “my agents write to my notes” into “my agents propose, I accept.” Your review queue is the diff. ### An AI company: one Brain per customer [Section titled “An AI company: one Brain per customer”](#an-ai-company-one-brain-per-customer) ```ts // Provisioning is a pure function of your own tenant id. async function brainFor(tenantId: string): Promise { const handle = `tenant:${tenantId}`; await fetch(`${API}/v1/brains/${encodeURIComponent(handle)}`, { method: "PUT", headers: { authorization: `Bearer ${process.env.NICIA_KEY ?? ""}`, "content-type": "application/json", }, body: JSON.stringify({ name: tenantId }), }); return handle; // your handle is the address — nothing to store, nothing to map } ``` Because your handle is the address and `PUT` is idempotent, there is no id-mapping table, no create/get branch, and no duplicate Brain to reconcile. See [One Brain per customer](/docs/scale/fleet/). ## Next [Section titled “Next”](#next) * **[Statements](/docs/write/statements/)** — what a source states, sources that disagree, decide. * **[Context](/docs/read/context/)** — token budgets, citations, and receipts. * **[Receipts](/docs/govern/receipts/)** — reconstructing a served read, and finding out when it went stale. * **[Schemas](/docs/schemas/overview/)** — when to leave the default alone and when not to. * **[Review](/docs/govern/review/)** — turning on approval, and settling the queue. # Start with your agent > Paste one prompt into Claude Code, Codex, or Cursor and let it build or fill your Brain for you. You already run a coding agent. It can do this for you. Each procedure below is a plain markdown file at a stable URL. You do not run it in a terminal. Either paste the whole file into your agent — the **Copy prompt** buttons on [the home page](/) do exactly that — or, if your agent can fetch URLs, point it at the link and let it read the file itself. Both work, and pasting the file is the one that works offline. Every one of these procedures **reads your sources and never writes back to them**. ## I already have the knowledge somewhere [Section titled “I already have the knowledge somewhere”](#i-already-have-the-knowledge-somewhere) A folder of markdown, a wiki export, an Obsidian vault, a docs directory in a repo — or pages in Notion, Drive, Confluence, or Slack that your agent can already reach. ```text Read https://nicia.ai/import.md and follow it to move my team's knowledge into a Nicia Brain. ``` Your agent will ask which sources to read and what to call the Brain, import what it finds through the same preview-and-receipt pipeline as direct upload, and verify the result by asking the Brain a question and showing you the citation that came back. It asks one question when the source is genuinely ambiguous and reports every skipped or partial file. Nothing in your existing tools is modified. What you get is a **copy**, which makes this cheap to try and cheap to abandon. A copy drifts once people keep authoring somewhere else, so run both for a week or two, compare the answers, and then decide whether the Brain becomes the place you write. Full detail: [Bring your knowledge](/docs/team/bring-it/). ## I want a Brain, or I am moving Brains to Nicia [Section titled “I want a Brain, or I am moving Brains to Nicia”](#i-want-a-brain-or-i-am-moving-brains-to-nicia) Nothing written down yet, adding per-customer Brains to a product you ship, or migrating Brains you keep somewhere else. Same procedure: it starts by asking what you already have and branches from there. ```text Read https://nicia.ai/build.md and follow it to set up Brains in Nicia with me. ``` If nothing is written down, your agent interviews you about what your team keeps re-explaining and writes those answers down as pages. It is told to record what you say rather than invent plausible-sounding content, and to leave gaps rather than fill them. If you already own the data, it provisions a Brain per tenant and syncs your records instead. Full detail: [Add a Brain](/docs/start/add-a-brain/). ## Prefer to click? [Section titled “Prefer to click?”](#prefer-to-click) None of this is required. Click **New Brain** in the app — one page shows every path: drop a folder or a `.zip` on “Upload what you have”, or name your Brain and let the built-in Brain Agent build it under “Build it right here”. It follows the same discovery rules as `build.md`, and its writes follow the Brain’s own write policy. A Brain created in the app starts reviewed, so the agent’s writes wait in Review until you approve them; switch the Brain to open and they land directly (see [Review](/docs/govern/review/)). The same agent handoff prompts these procedures use are also on that page, under “Use your coding agent”, if you’d rather copy one from the app than come back here. See [Bring your knowledge](/docs/team/bring-it/). ## Next [Section titled “Next”](#next) * **[Bring your knowledge](/docs/team/bring-it/)** — the import path in detail. * **[Share it with your team](/docs/team/share-it/)** — connect Claude Code, Codex, and any MCP client. * **[Keep it good](/docs/team/tend-it/)** — review, maintenance, and export. # Bring your knowledge > Get your team's notes off one laptop and into a Brain your whole team can ask. If your team’s knowledge is a folder of markdown — a wiki export, an Obsidian vault, a docs repo, a pile of meeting notes in Drive — this is the shortest path to something the whole team can browse, search, and connect their agents to. It does not have to be a folder. If your knowledge lives in Notion, Drive, Confluence, or Slack, the agent you already run has access to those, and that makes it the connector: hand it [the import procedure](https://nicia.ai/import.md) and it will materialize those pages into the same faithful import pipeline. For a local folder or ZIP, direct upload is the shortest path and produces the same per-file receipt. Either way this **reads, and never writes back**. Nothing in your existing tools is modified or moved, no sync is set up, and no credential of ours touches your systems. You get a copy, so trying costs you nothing. A copy drifts once people keep authoring somewhere else, so run both for a week or two, compare the answers, and then decide whether the Brain becomes the place you write. ## Drag the folder in [Section titled “Drag the folder in”](#drag-the-folder-in) Open [app.nicia.ai](https://app.nicia.ai), choose **New Brain**, and drop a folder or a `.zip` on the upload panel. Nicia scans first and creates nothing until you confirm the preview. ```plaintext Scanning team-wiki.zip 312 markdown files, 4.1 MB 312 documents, 47 links Creating Brain team:acme Uploading ████████████████████ 312/312 Indexing ████████████████████ done Brain ready. ``` That is the whole thing, and **nothing you already built is lost on the way in**: | What you have | What it becomes | | ---------------------- | ------------------------------------------------ | | Folder structure | Each document’s source path, kept as metadata | | Frontmatter keys | Preserved as document metadata | | `[[wikilinks]]` | Kept in the document text; unresolved ones named | | A `type:` you invented | Source metadata; classify it later if useful | | Anything else | Prose, searchable and citable | Every file lands as a **document**: its own bytes, verbatim, with its path and frontmatter beside it. The import types nothing and writes no records, so a wrong `type:` in a source file can never put a wrong fact into the Brain. If you curated this by hand, had an LLM organise it, or brought it from GBrain, that work is all still there in the documents, and the Brain Agent can turn it into typed records afterwards, each one a proposal you review. Keys we do not recognise are **stored, not dropped**. See [Schemas](/docs/schemas/overview/). Re-run the same import any time. The receipt identifies unchanged files, versions edits, preserves unambiguous moves, and reports conflicts or missing source files without erasing either side. ## Run the same import with your coding agent [Section titled “Run the same import with your coding agent”](#run-the-same-import-with-your-coding-agent) Copy [the import procedure](https://nicia.ai/import.md) into Claude Code, Codex, or Cursor. It uses the same API sequence as the app: provision the stable Brain handle, upload one ZIP for a read-only preview, show you the scan, wait for your confirmation, commit the durable import, and verify a cited answer. The agent must account for every source entry and report partial results. It is told to ask one question when the source root or privacy scope is ambiguous and to stop truthfully when it cannot read the source. There is no separate CLI implementation with different fidelity rules. ## What you get [Section titled “What you get”](#what-you-get) A document per file, browsable immediately and indexed for search as the import settles. The built-in Brain Agent then proposes typed records from those documents, each grounded in a quote from the file it came from. Nothing it infers lands until a person accepts it in Review, and the document itself is never rewritten. Ask the Brain something in [app.nicia.ai](https://app.nicia.ai), or from a connected agent once you’ve [shared it with your team](/docs/team/share-it/) — “who owns billing?” comes back grounded in the actual documents, with a citation for every claim: > Billing is owned by the Payments squad; Priya Raman has been the DRI since the March reorg. > > — `handbook/teams/payments.md`, `notes/2026-03-11-reorg.md` If an answer looks wrong, the file it came from is one click away. ## What if my markdown is messy? [Section titled “What if my markdown is messy?”](#what-if-my-markdown-is-messy) That is the normal case and it is fine. Import never fails on structure it does not recognise — untyped files become plain documents, which are still fully searchable and citable. Structure is something you add later if you want it, not a precondition. If you want to see what Nicia thinks your shape is, `GET /v1/brains/{handle}/schema/candidates` reports the field patterns it noticed across your files (`person — 41 files — fields: name (98%), team (71%)`, and so on); `POST /v1/brains/{handle}/schema/promote` turns selected recurring keys into a governed additive-schema proposal. Nothing changes until Review accepts it, and skipping every suggestion leaves the Brain complete as it is. ## Next [Section titled “Next”](#next) * **[Share it with your team](/docs/team/share-it/)** — invite people, connect Claude Code and Codex. * **[Keep it good](/docs/team/tend-it/)** — review, and letting agents contribute. # Share it with your team > Invite people, and connect Claude Code, Codex, and any other MCP client to the same Brain. Once your knowledge is in a Brain, the point is that everyone — and everyone’s agents — reads the same copy. ## Invite people [Section titled “Invite people”](#invite-people) From **Settings → Members** in [app.nicia.ai](https://app.nicia.ai), invite someone by email and pick their role. Accepting the invite creates their organization membership first, and the Brain access they need is granted against that membership — so the review inbox and connecting an agent both work the moment they accept, with nothing to re-authorize. ## Connect Claude Code, Codex, Cursor, or any MCP client [Section titled “Connect Claude Code, Codex, Cursor, or any MCP client”](#connect-claude-code-codex-cursor-or-any-mcp-client) Every Brain has one MCP endpoint, addressed by its handle: ```plaintext https://api.nicia.ai/mcp/brains/{handle} ``` There are two ways to sign in to it. **In a browser.** Paste the URL into a client that speaks MCP OAuth and it opens a browser to sign in as you. Nothing you paste is a secret. The access token the client receives is scoped to this Brain, carries read and write, expires on its own, and appears in the Brain’s key list as `OAuth: `, where you can revoke it like any other key. **With a key.** Open the Brain in [app.nicia.ai](https://app.nicia.ai) and press **Connect**. The dialog mints a key scoped to exactly this Brain and shows the credential once. Copy it before you close the dialog — it is not shown again, and the fix for a lost one is to rotate the key, not to look it up. A headless or CI client cannot open a browser, so this is its path. The key is a secret; the URL is not. Keep the key in your environment rather than in the config file, so the file stays safe to commit and to share with a teammate who has their own key. **Claude Code** — add to `.mcp.json`: ```json { "mcpServers": { "nicia-brain": { "type": "http", "url": "https://api.nicia.ai/mcp/brains/{handle}", "headers": { "Authorization": "Bearer ${NICIA_BRAIN_TOKEN}" } } } } ``` **Codex** — add to `~/.codex/config.toml` or `.codex/config.toml`: ```toml [mcp_servers.nicia_brain] url = "https://api.nicia.ai/mcp/brains/{handle}" bearer_token_env_var = "NICIA_BRAIN_TOKEN" ``` **Cursor** — add to `~/.cursor/mcp.json`: ```json { "mcpServers": { "nicia-brain": { "url": "https://api.nicia.ai/mcp/brains/{handle}", "headers": { "Authorization": "Bearer ${env:NICIA_BRAIN_TOKEN}" } } } } ``` **Any other MCP client** — Streamable HTTP against that same URL, sending `Authorization: Bearer $NICIA_BRAIN_TOKEN`. Prefer your client’s environment-backed secret setting over a literal in a config file. Then export the key and restart the client: ```bash export NICIA_BRAIN_TOKEN="nbk_…" ``` The URL names the Brain the way everything else on this site does, by its handle. The key in the header decides which Brain it may reach, so a handle that belongs to some other Brain answers the same `404` as one that does not exist. To check the key before wiring a client to it, ask for the Brain it opens: ```bash curl --fail-with-body "https://api.nicia.ai/v1/brains/{handle}" \ -H "Authorization: Bearer $NICIA_BRAIN_TOKEN" ``` Planned — not published yet A one-command version of this page exists in the repository but **is not installable today**: `packages/cli` is unpublished, so `npx nicia connect team:acme` resolves nothing from the npm registry. Use the Connect dialog above. When the CLI does ship it will create the Brain in `reviewed` mode if it does not exist, mint a key named `nicia-connect//` — so re-running it on the same laptop reuses that key instead of minting a second one — and merge one entry into the target client’s config, leaving every other MCP server in that file alone. The key gets `read` and `write`; `--read-only` narrows it to `read`. It will print: ```plaintext Using existing Brain team:acme (mode reviewed; name, purpose, and mode left as they are) Minted a Brain key. Claude Code — /Users/priya/.claude.json: added MCP server "nicia-team-acme" Restart Claude Code and ask it something from team:acme. ``` It authenticates with an organization key sent as `Authorization: Bearer`, and there is deliberately no `--key` flag — a secret on the command line lands in shell history and in `ps` output for every user on the machine, so it will only ever take one from `NICIA_API_KEY` or an echo-suppressed prompt. ## What an agent can do with it [Section titled “What an agent can do with it”](#what-an-agent-can-do-with-it) A key is a credential scoped to exactly this Brain, and the tools it sees follow its capabilities: `read` shows the seven that read, `write` adds the two that write, `read` and `write` together add the one that proposes a schema change, and `review` adds the one that says who was served what. There are eleven, and together they cover what an agent needs: read what the Brain knows, browse and cite its documents, notice what changed, put something new into it, give it a shape it lacks, see what is waiting for review, and account for what it was shown. | Tool | Does | | ---------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `brain_context` | Semantic or open-ended retrieval over accepted records and source documents, with citations, a `staged` count and `warnings` as [`POST /context`](/docs/read/context/) reports them, and a receipt. Use this when you do not know an exact identity or slug. | | `brain_query` | Deterministic filtering by record kind, semantic id, slug, exact link, and typed predicates on declared fields (`eq`, `ne`, `in`, `has`, ranges on number, date, datetime). Not semantic search; use `brain_context` for open-ended questions. | | `brain_schema` | Read the Brain’s exact kinds, fields, links, and `declaredVersion`. When the credential can read, call it before writing typed records and again after `INVALID_RECORD_INPUT`. | | `brain_events` | What changed since the agent last looked, as ids, field names, and versions — never values. Pass the returned `cursor` back as `after`. Preview; see [Events and webhooks](/docs/govern/events/). | | `brain_write_document` | Save markdown as source material, not typed facts. Its `action` says whether indexing is complete or the caller should wait. | | `brain_write_records` | Create or update typed facts atomically. Preserve returned tokens for `ifMatch` updates and send an `idempotencyKey` for safe retries. Its `action` says whether knowledge changed or review is required. | | `brain_evolve_schema` | Propose a kind, field, or link the Brain does not declare yet: the complete schema `brain_schema` returned, plus the additions. Changing an existing declaration (an enum value list, a safeguard) needs an `admin` key. The Brain’s mode decides, as it does for records: it lands on an `open` Brain and waits in review on a `reviewed` one. | | `brain_documents` | List the documents the Brain serves, each with its outline (every heading as the exact `anchor` to cite), or read one document or one section of it. Reading text is receipted. Cite what you read in a record’s `evidence` with the same `anchor`; see [Cite your sources](/docs/write/records/#cite-your-sources). A branch key cannot use it. Preview. | | `brain_receipts` | List the receipts this key committed (every key’s with `admin`), filtered by consumer, kind, key and time window, or get one by `receiptId` with the version of every source it pinned. Preview. | | `brain_reads` | Who was served one record, document or source document, with the version each read was shown and how it stands now; `stale: true` for only the reads a correction made stale. Needs `review`. Preview. | | `brain_proposals` | List review Proposals by source kind and status, or get one with its revisions, member ChangeSets and diff. Read-only; a branch key sees only its branch’s. Preview. | There is no tool that approves a change. An agent proposes; a person decides. There is also no tool that writes an answer for you: `brain_context` hands your agent the Brain’s knowledge and your agent’s own model does the answering. So “check the handbook before you answer” stops being a thing you paste into a prompt and starts being something the agent can do. For a simple request such as “Create a page named Onboarding notes”, the agent can call `brain_write_document` with `title` and `text`. The server handles the content digest and retry key, and the document is searchable through `brain_context` as soon as it is indexed. Indexed text and typed records are separate states: to put a typed fact into the Brain, call `brain_write_records`. Correcting something the Brain already knows is the same call again with the new value, or a new document that says so — there is no separate correction tool. Every tool publishes a valid input example alongside its JSON Schema. The structured content of a staged record write includes the schema version used for validation: ```json { "action": { "outcome": "staged", "knowledgeChanged": false, "nextAction": "wait_for_review" }, "validatedAgainst": { "declaredVersion": "schema-v7" } } ``` Safe failures are equally explicit. Their text content is JSON with a stable code, whether the identical request is retryable, a concrete `nextAction`, and `nextTool` when another available tool is the recovery path. On a read/write connection, for example, `INVALID_RECORD_INPUT` points to `brain_schema` and `RECORD_WRITE_CONFLICT` points to `brain_query`. A model should never blindly repeat either one. A record whose `kind` the Brain does not declare is refused, and the refusal says so: its `action` lists the `undeclaredKinds` and points to `brain_evolve_schema`, or, for a key that cannot read the schema, tells the agent to ask its operator. Dropping the `kind` to file the record as an untyped note is the wrong recovery — its fields would be stored but not queryable. ## What an agent writes waits for you [Section titled “What an agent writes waits for you”](#what-an-agent-writes-waits-for-you) Whether a connected agent’s write lands or waits is decided by the Brain, not by the credential. On a `reviewed` Brain a `brain_write_records` call is still typed and validated, but it stages instead of landing — the change waits in the queue, and nothing changes until somebody accepts it. The tool’s answer says `staged` and names the change, which is the id you find in the queue. Check the mode before you hand a key to an agent that writes. A Brain created over the API with [`PUT /v1/brains/{handle}`](/docs/reference/) is `open` unless the request states `"mode": "reviewed"`, and minting a key never changes the mode of a Brain that already exists — so a key against an open Brain writes into it unreviewed. The switch is one call: ```bash curl -X PATCH "$API/v1/brains/team:acme" \ -H "authorization: Bearer $NICIA_KEY" \ -H "content-type: application/json" \ -d '{ "mode": "reviewed" }' ``` ```bash curl "$API/v1/brains/team:acme/changes?status=staged" \ -H "authorization: Bearer $NICIA_KEY" ``` Settle it — from the same call, or from the review inbox in [app.nicia.ai](https://app.nicia.ai), which shows the diff and the source side by side: ```bash curl -X POST "$API/v1/brains/team:acme/changes/chg_44a1e0/lifecycle/approve" \ -H "authorization: Bearer $NICIA_KEY" \ -H "content-type: application/json" \ -d '{}' ``` `mode` is the Brain’s own setting, so it governs every connected agent at once, and no credential carries an override of its own. If a pipeline needs to write straight in while agents are reviewed, give it its own Brain. See [Keep it good](/docs/team/tend-it/) and [Review](/docs/govern/review/) for the full model. ## Everyone reads the same thing [Section titled “Everyone reads the same thing”](#everyone-reads-the-same-thing) The point of the shared copy is that a correction lands once. When Priya fixes the handbook, the next question anyone’s agent asks gets the corrected answer — no re-syncing, no stale local vault, no “which copy is right.” ## Next [Section titled “Next”](#next) * **[Keep it good](/docs/team/tend-it/)** — review, contributions, and letting agents help maintain it. * **[Handing out keys](/docs/scale/brain-keys/)** — scoped keys for services and pipelines that don’t connect over MCP. # Keep it good > Review contributions, let agents help maintain it, and take your knowledge with you. A shared brain decays the same way a wiki does — unless contributing is easy and accepting is deliberate. That is what review is for. ## The queue [Section titled “The queue”](#the-queue) Everything an agent proposes waits here with the passage behind it. Settle it in [app.nicia.ai](https://app.nicia.ai), which shows the diff and the source side by side, or over the API: ```bash curl "$API/v1/brains/team:acme/changes?status=staged" \ -H "authorization: Bearer $NICIA_KEY" curl -X POST "$API/v1/brains/team:acme/changes/chg_44a1e0/lifecycle/approve" \ -H "authorization: Bearer $NICIA_KEY" curl -X POST "$API/v1/brains/team:acme/changes/chg_44a1e0/lifecycle/publish" \ -H "authorization: Bearer $NICIA_KEY" \ -H "content-type: application/json" \ -H "idempotency-key: publish-chg_44a1e0" \ -d '{}' curl -X POST "$API/v1/brains/team:acme/changes/chg_51c8b2/lifecycle/reject" \ -H "authorization: Bearer $NICIA_KEY" \ -H "content-type: application/json" \ -d '{ "reason": "Shipped to staging, not prod" }' ``` Approving binds the change and publishing applies it; a rejection needs a `reason`. To take only part of a change, `select` its operations first. You are always approving **evidence**, not an assertion. If a proposal has no passage behind it, that is itself a reason to reject it. See [Review](/docs/govern/review/) for the full model. ## Who writes directly [Section titled “Who writes directly”](#who-writes-directly) `mode` sets the policy default, not a per-write override. On an `open` Brain most writes land immediately; on a `reviewed` one most agent writes wait, but your own edits and additive schema still land, and destructive changes always wait on both modes. No credential can ask for an exception, because an exception a caller can request is an exception an agent can request. When a service genuinely needs to write straight in while people’s agents are reviewed, give it its own Brain and read across both. See [Review](/docs/govern/review/). ## Let an agent tend it [Section titled “Let an agent tend it”](#let-an-agent-tend-it) Planned — ships with the kernel Not shipped yet. Nicia’s built-in Brain Agent reads what has landed and proposes maintenance: contradictions between two documents, pages that have gone stale, questions people keep asking that nothing answers. ```bash npx nicia tend team:acme # or POST /v1/brains/team:acme/tend ``` ```plaintext Proposed 4 changes: · handbook/oncall.md contradicts notes/2026-07-02-oncall-change.md on the escalation path · 3 documents reference "Project Mercury" — no page defines it · handbook/pricing.md last updated 2026-01-14, cited 41 times since ``` It always proposes and can never write directly. Run it on a schedule and the queue becomes a to-do list for keeping the brain honest. ## Standing questions [Section titled “Standing questions”](#standing-questions) For the questions your team asks constantly, keep a maintained answer rather than re-deriving it every time. Install the `standing-answers` schema pack with `POST /v1/brains/{id}/schema-packs` and `{ "packId": "standing-answers" }`. That one is a console-plane call, not part of the Brain API: it takes the Brain’s platform `id` — the `id` field `PUT`/`GET /v1/brains/{handle}` returns — rather than the handle, and an organization key rather than a Brain key. Once it is installed, the console’s Pages rail pins a “Standing answers” group above source material, with pending updates routed to Review like any other change. Planned — ships with the kernel A dedicated authoring shortcut is not shipped yet: `npx nicia standing add` and `PUT /v1/brains/team:acme/answers/{id}` do not exist. Author a standing answer as a typed record against the pack’s kinds instead. Cite the sections it rests on with an `anchor` so it is monitored; see [An answer your own agent maintains](/docs/read/context/#an-answer-your-own-agent-maintains). Installing the pack gives you the kinds, not the maintenance. The maintenance agents and the rebuild automation are configured separately — the Brain’s maintained-knowledge panel in the console walks through that setup and reports what is still missing. Once it is complete, Nicia keeps the answer current as the underlying documents change and tells you when the sources behind it move, so a stale answer becomes a review item instead of a wrong answer. ## It is your knowledge [Section titled “It is your knowledge”](#it-is-your-knowledge) Export the whole Brain, any time, and take it somewhere else. You arrived with a folder. You leave with a model. No proprietary format, no lock-in, no support ticket. From the API, an export is an asynchronous job: start it, poll it, download the archive. It needs the `read` capability, because an export carries the whole Brain out. ```bash curl -X POST "$API/v1/brains/team:acme/exports" \ -H "authorization: Bearer $NICIA_KEY" \ -H "idempotency-key: nightly-2026-09-21" curl "$API/v1/brains/team:acme/exports/0198b6a3-4c5d-7e8f-9a0b-1c2d3e4f5a6b" \ -H "authorization: Bearer $NICIA_KEY" curl -o brain-export.zip \ "$API/v1/brains/team:acme/exports/0198b6a3-4c5d-7e8f-9a0b-1c2d3e4f5a6b/download" \ -H "authorization: Bearer $NICIA_KEY" ``` Start answers `202` with a `queued` export. Poll until its `status` is `completed`; the response then carries the `downloadPath` the archive is fetched from. Repeating a start under the same `Idempotency-Key` returns the export the first request created instead of a second one, and a key can have one export running at a time — a second start is a `409`. Only the key that started an export can read or download it. In TypeScript, `@nicia/brain-client` wraps the three calls as `startExport`, `waitForExport`, and `downloadExport`. The `.zip` holds: * your **documents**, at the address you gave each one (`notes/renewal.md` comes back as `notes/renewal.md`) or the path they were imported from, byte for byte; * every **typed record** as the Markdown it is stored as — frontmatter and body — under `records//.md`, plus `records.json`, its structured projection; * `evidence.json`, the evidence behind each record, with anchors and your own locators intact, and `history.json` plus `blobs/`, the complete revision history with every version’s exact bytes; * `receipts.json`, the read receipts the exporting key can list — the ones it committed, or every key’s when it holds `admin` — each with its snapshot, hashes and the version of every source it pinned, but not the text a read was handed; * `answers.json`, each standing answer’s validity as of the export; * `manifest.json`, whose root hash covers the documents and their history — the hash the export reports as `manifestRootHash`; * `index.md` cataloguing every page, `log.md` as a greppable activity log, and an `AGENTS.md` describing the Brain’s own conventions, so any agent can pick the export up and work with it. What leaves is accepted knowledge and the record of what was served from it. A change still waiting in Review is not part of the Brain yet, so it is not exported, and neither is the external operation ledger. The Brain menu in [app.nicia.ai](https://app.nicia.ai) (“Export brain”) downloads a lighter `.zip` in one click. It carries your documents, not your accepted state: `records.json` and `evidence.json` come out empty, and `history.json` holds a single genesis entry per document rather than its revision chain. Use the API export when you need everything. That is the point of the boundary being ours and the knowledge being yours: what leaves is structurally better than what arrived, and it is legible without us. ## Next [Section titled “Next”](#next) * **[Review](/docs/govern/review/)** — the API underneath the queue. * **[Receipts](/docs/govern/receipts/)** — proving what an answer was based on. # Build an agent company on Nicia > A complete worked build — a Brain per customer, their context in it, an agent reading it, and a cited answer scoped to exactly one customer. You sell an AI product. Every customer of yours has their own accounts, documents, history and vocabulary, and your agent is only as good as what it knows about **that** customer. This page builds the layer underneath it, start to finish, in one sitting. Every command below was run against a live API before it was published. By the end you will have two customers, isolated from each other, a document and a typed record in one of them, an agent credential that reaches one Brain and no other, and a cited answer with a receipt. What you will **not** have is a replacement for your agent. Nicia is the thing your agent asks before it answers. ## Before you start [Section titled “Before you start”](#before-you-start) You need one credential, and getting the first one is interactive. Sign in at [app.nicia.ai](https://app.nicia.ai) → **Settings → API keys** → mint one under **Organization keys**. That is a management key (`nsk_`), it is shown once, and it reaches every Brain in your organization. ```bash export NICIA_KEY="nsk_…" export API="https://api.nicia.ai" ``` Budget for one person in a browser, once. Everything after this is automatable — including minting more keys, over `POST /v1/keys`, which itself needs a signed-in session rather than a key. See [Authentication](/docs/start/authentication/). Every response is `{ "data": … }` on success and `{ "error": { "kind", "message" } }` on failure. ## 1. A Brain per customer, with no id to store [Section titled “1. A Brain per customer, with no id to store”](#1-a-brain-per-customer-with-no-id-to-store) Provisioning is a pure function of the tenant id you already have. ```bash curl -X PUT "$API/v1/brains/tenant:acme" \ -H "authorization: Bearer $NICIA_KEY" \ -H "content-type: application/json" \ -d '{ "name": "Acme", "purpose": "Everything we know about Acme." }' ``` ```json { "data": { "brain": { "id": "e72iQTc7tg4e7JTDPCvju", "generation": "d008bb06-3bcc-4f65-bdaa-e8bd78ea188d", "handle": "tenant:acme", "name": "Acme", "purpose": "Everything we know about Acme.", "schema": "nicia-base", "mode": "open", "createdAt": "2026-08-28T06:58:05.027Z", "updatedAt": "2026-08-28T06:58:05.027Z" } } } ``` The first call answers `201`. Run the identical command again and it answers `200` with the same `id`. That is the whole point of addressing a Brain by **your** handle: provisioning is one line in your signup path, it is safe to call on every request, and there is no create-or-get branch, no id-mapping table, and no reconciliation job. Two defaults worth knowing before you build on them: * **`schema: "nicia-base"`** — a starter vocabulary you did not have to design: the kinds `person`, `company`, `deal`, `concept` and `note`, plus the links between them. It declares **no fields at all**, which matters more than it sounds like; see [What you cannot query](#what-you-cannot-query). * **`mode: "open"`** — most writes land as they arrive; destructive changes still wait for approval. Switch to `"reviewed"` when you want a human between an agent and the record. [Tutorial 2](/docs/tutorials/onboarding/) builds the reviewed path in full. `generation` is the Brain’s lifecycle fence. You do not need it for anything on this page. There is a Brain limit, and it is small until you verify your email An account whose email address has not been confirmed is capped at **two Brains**. This page creates exactly two, so you can follow it to the end and still hit the ceiling on your next one. Confirming the address lifts the cap to your plan’s limit. The refusal is a `429` whose message currently suggests raising a server-side maximum — for a hosted account, the lever is verification, not that setting. ## 2. Put the customer’s context in [Section titled “2. Put the customer’s context in”](#2-put-the-customers-context-in) Documents and typed records are peer on-ramps. Send whichever you have; most companies have both. ```bash curl -X POST "$API/v1/brains/tenant:acme/documents" \ -H "authorization: Bearer $NICIA_KEY" \ -H "content-type: application/json" \ -d '{ "id": "hubspot:deal:412", "title": "Acme renewal call", "text": "Renewal call 2026-03-14. Acme renewed at $120k for 24 months. Bob Smith (Head of Platform) pushed for SSO before Q3.", "metadata": { "source": "hubspot", "type": "call-note" } }' ``` `id` is yours — use the id from the system the content came from. Sending it again replaces that document, so your nightly sync is a loop of `POST`s with no “have I sent this already” table behind it. Nicia also reads the prose and proposes structured records of its own, unless you send `"extract": false`. Those arrive as records of kind `statement`, on a fixed vocabulary that is not the one you declare — so your `/context` may carry blocks beyond the two shown below. [Tutorial 2](/docs/tutorials/onboarding/) covers when to leave that on and when to do the extraction yourself. ```bash curl -X PATCH "$API/v1/brains/tenant:acme/records/bob-smith" \ -H "authorization: Bearer $NICIA_KEY" \ -H "content-type: application/json" \ -d '{ "kind": "person", "fields": { "name": "Bob Smith", "role": "Head of Platform" } }' ``` ```json { "data": { "record": { "id": "bob-smith", "kind": "person", "undeclared": ["name", "role"], "token": "brain-record-token-v1:9f2c4b6a1d0e8f735a4c2b9e1d6f0a3c8b5e7d2a4f6c9b1e3d5a7f0c2b4e6d8a", "updatedAt": "2026-08-28T06:58:06.902Z" }, "change": { "id": "01a04729-5750-7e17-ad52-3e79c0f4b91c", "status": "applied", "fields": ["name", "role"], "createdAt": "2026-08-28T06:58:06.783Z" } } } ``` Read `undeclared` carefully — it is the honest half of this response. `name` and `role` were **stored**, and they will come back on every read, but `nicia-base` does not declare them as fields on `person`, so you cannot filter on them. Nothing was dropped and nothing needs re-importing — a declared field governs and returns values that are already there, which is why sending data before you have finished modelling it costs you nothing. What it does mean is that you cannot filter on them until they are declared, and declaring happens on the create call: `nicia-core` declares `name` and `role` on `person`, and `nicia-base` declares no fields at all. See [what you cannot query](#what-you-cannot-query). ## 3. Give the agent its own key [Section titled “3. Give the agent its own key”](#3-give-the-agent-its-own-key) Your agent process serves one customer at a time. Give it a credential that can reach one Brain and nothing else — that is the difference between a bug and an incident. ```bash curl -X POST "$API/v1/brains/tenant:acme/keys" \ -H "authorization: Bearer $NICIA_KEY" \ -H "content-type: application/json" \ -d '{ "name": "acme copilot", "capabilities": ["read"] }' ``` ```json { "data": { "key": { "id": "01a04729-59eb-7604-a5e7-e2cae0993b51", "name": "acme copilot", "capabilities": ["read"], "status": "active", "createdAt": "2026-08-28T06:58:07.464Z" }, "secret": "nbk_f84c32bd…" } } ``` `secret` is shown once. It is a Brain key (`nbk_`), bound to `tenant:acme`, and it carries `read` only — so it can ask questions and cannot change anything. Mint one per customer in the same place you provision the Brain. ```bash export ACME_KEY="nbk_…" ``` ## 4. The call your agent actually makes [Section titled “4. The call your agent actually makes”](#4-the-call-your-agent-actually-makes) ```bash curl -X POST "$API/v1/brains/tenant:acme/context" \ -H "authorization: Bearer $ACME_KEY" \ -H "content-type: application/json" \ -d '{ "prompt": "What should I know before the renewal call?", "maxTokens": 2000 }' ``` ```json { "data": { "text": "Renewal call 2026-03-14. Acme renewed at $120k for 24 months. Bob Smith (Head of Platform) pushed for SSO before Q3.\n\nbob-smith (person)\nname: Bob Smith\nrole: Head of Platform", "citations": [ { "source": "hubspot:deal:412", "slug": "brain-evidence-f5ca6d66b4c42e5388ca08a0dade8cf01c76c008fdd49c787267672982ec73e8", "quote": "Renewal call 2026-03-14. Acme renewed at $120k for 24 months. Bob Smith (Head of Platform) pushed for SSO before Q3.", "recordedAt": "2026-08-28T06:58:06.037Z" }, { "slug": "bob-smith", "quote": "bob-smith (person)\nname: Bob Smith\nrole: Head of Platform", "recordedAt": "2026-08-28T06:58:06.902Z" } ], "staged": 0, "warnings": [], "receipt": "01a04729-633a-7086-b63e-b63896318510", "truncated": false } } ``` Paste `text` into your prompt. Render `citations` to your user. Log `receipt`. Three properties of that response are worth building on: * **Block *n* of `text` is `citations[n].quote`, character for character.** So whatever you show a user as a citation is exactly what the model was shown. A citation can never be a paraphrase. * **No model wrote any of it.** Nothing is summarised or generated; every word came out of your Brain. A reranker may score the leading candidates on deployments that have one, but nothing composes prose. * **`warnings` is empty, which is a claim.** When it is not empty, the answer is telling you it is less than the whole truth. See [step 6](#6-what-a-tight-budget-buys-you). Wire it up: ```ts const response = await fetch(`${API}/v1/brains/${handle}/context`, { method: "POST", headers: { authorization: `Bearer ${brainKeyFor(customerId)}`, "content-type": "application/json", }, body: JSON.stringify({ prompt: userQuestion, maxTokens: 2000 }), }); const { data } = await response.json(); const answer = await model.complete({ system: `Use only the context below.\n\n${data.text}`, messages: [{ role: "user", content: userQuestion }], }); // Keep data.receipt beside the answer you store. ``` That deletes your prompt assembler. It is one round trip, and no language model is asked to write anything inside it — see [Cost and latency](/docs/read/context/#cost-and-latency). ## 5. Prove the isolation before you trust it [Section titled “5. Prove the isolation before you trust it”](#5-prove-the-isolation-before-you-trust-it) Provision a second customer and try to cross the line: ```bash curl -X PUT "$API/v1/brains/tenant:globex" \ -H "authorization: Bearer $NICIA_KEY" \ -H "content-type: application/json" \ -d '{ "name": "Globex" }' curl -X POST "$API/v1/brains/tenant:globex/context" \ -H "authorization: Bearer $ACME_KEY" \ -H "content-type: application/json" \ -d '{ "prompt": "anything", "maxTokens": 500 }' ``` ```json { "error": { "kind": "not_found", "message": "No Brain is addressable as 'tenant:globex'." } } ``` `404`, not `403` — a Brain key does not confirm that another customer’s Brain exists. The read-only key is refused a write in the same way: ```bash curl -X PATCH "$API/v1/brains/tenant:acme/records/bob-smith" \ -H "authorization: Bearer $ACME_KEY" \ -H "content-type: application/json" \ -d '{ "fields": { "role": "VP Platform" } }' ``` ```json { "error": { "kind": "forbidden", "message": "This Brain key does not hold the 'write' capability." } } ``` And revocation takes effect on the next request, everywhere: ```bash curl -X DELETE "$API/v1/brains/tenant:acme/keys/01a04729-59eb-7604-a5e7-e2cae0993b51" \ -H "authorization: Bearer $NICIA_KEY" ``` The next `/context` on that secret is a `401`. Run these three checks against your own build; they are the ones an audit will ask about. ## 6. What a tight budget buys you [Section titled “6. What a tight budget buys you”](#6-what-a-tight-budget-buys-you) `maxTokens` is a budget, not a row count, and under a budget **render order is selection order** — so the budget is where you find out whether retrieval is doing anything. Add three more documents, two of them irrelevant: ```bash curl -X POST "$API/v1/brains/tenant:acme/documents" \ -H "authorization: Bearer $NICIA_KEY" \ -H "content-type: application/json" \ -d '{ "documents": [ { "id": "zendesk:ticket:8821", "title": "SSO login loop after IdP change", "text": "Customer reports a redirect loop after switching to Okta. Resolved same day by clearing the stale assertion cache.", "extract": false }, { "id": "notion:page:rota", "title": "Warehouse rota planning", "text": "Weekly operations note. Shift handover, forklift maintenance scheduling, and agency cover for the night rota.", "extract": false }, { "id": "notion:page:offsite", "title": "Team offsite notes", "text": "Offsite agenda: roadmap review, a session on hiring, and a walk. Lunch is at one. Nothing customer-facing here.", "extract": false } ] }' ``` Then ask three different questions with a budget too small to hold everything: ```bash curl -X POST "$API/v1/brains/tenant:acme/context" \ -H "authorization: Bearer $ACME_KEY" \ -H "content-type: application/json" \ -d '{ "prompt": "Why did Acme have a login loop?", "maxTokens": 300 }' ``` The first citation is `zendesk:ticket:8821`. Ask `"What should I know before the renewal call?"` and it is `hubspot:deal:412`. Ask `"What is the forklift maintenance situation?"` and it is `notion:page:rota`. Same Brain, same budget, three different answers — and each one says what it left out: ```json { "data": { "truncated": true, "omitted": 2, "warnings": [ { "kind": "truncated", "count": 2, "detail": "The token budget cut this answer short; 2 sources did not fit." } ] } } ``` Two signals are fused. The **passage index** ranks a source by its text, and a **declared field** ranks one by holding something your prompt named — which is how a typed record with no prose at all is retrievable. Neither penalises the other. On this Brain only the first leg is doing work, because `nicia-base` declares no fields; [Tutorial 2](/docs/tutorials/onboarding/) declares some and gets both. Read `truncated: true` as “you have not seen everything”, never as “the rest did not matter”, and go to `/query` when you need specific rows rather than a budgeted answer. ## 7. When you want rows, not prose [Section titled “7. When you want rows, not prose”](#7-when-you-want-rows-not-prose) ```bash curl -X POST "$API/v1/brains/tenant:acme/query" \ -H "authorization: Bearer $ACME_KEY" \ -H "content-type: application/json" \ -d '{ "kind": "person" }' ``` And ranked passage retrieval, when you want the source rather than an assembled answer: ```bash curl -G "$API/v1/brains/tenant:acme/search" \ -H "authorization: Bearer $ACME_KEY" \ --data-urlencode "q=Okta redirect loop" \ --data-urlencode "limit=5" ``` ```json { "data": { "results": [ { "source": "zendesk:ticket:8821", "slug": "brain-evidence-be8712c31353463c6f94f8726cc3777c0d688b349eadf410ffc99714353370ee", "title": "Evidence zendesk:ticket:8821", "excerpt": "Customer reports a redirect loop after switching to Okta. Resolved same day by clearing the stale assertion cache.", "rank": 1, "version": 1 } ], "mode": "fulltext", "staged": 0, "warnings": [], "receipt": "01a04729-657c-73f8-b9bc-eb0236443e01" } } ``` `mode` echoes what the answer was **actually** ranked by. Asking for `hybrid` on a deployment with no embedding provider gives you `fulltext` and says so, rather than returning a keyword ranking dressed as a semantic one. ### What you cannot query [Section titled “What you cannot query”](#what-you-cannot-query) This is the first wall most builds hit, so hit it here instead of in production: ```bash curl -X POST "$API/v1/brains/tenant:acme/query" \ -H "authorization: Bearer $ACME_KEY" \ -H "content-type: application/json" \ -d '{ "kind": "person", "where": { "name": "Bob Smith" } }' ``` ```json { "error": { "kind": "validation", "message": "Query predicate field \"name\" is not declared on kind \"person\". Only declared fields are queryable; declare it in the schema to index it." } } ``` `name` was stored, and it comes back on every read. It is not queryable because `nicia-base` does not declare it, and the endpoint will not scan every stored record to pretend otherwise — a silently empty page would be worse. The schema decides what is queryable, never what is storable. Ways forward: * Query by `slug` when you hold the record’s own id; `slug` is always queryable. * Declare the fields you filter on. On a Brain created with `nicia-core`, `{ "where": { "name": "Bob Smith" } }` works, and so do `ne`, `in`, and ranges on number, date, and datetime fields — see [Search and queries](/docs/read/search/#query--rows). * For a fleet whose vocabulary will keep growing, name [an organization schema](/docs/schemas/overview/#your-organizations-own-schema) by slug on the create call so every Brain shares the same governed fields. [Tutorial 2](/docs/tutorials/onboarding/) and [Custom schemas](/docs/schemas/custom/) cover declaring that vocabulary. ## 8. The receipt [Section titled “8. The receipt”](#8-the-receipt) Every read commits one, and it is what makes an answer defensible months later: ```bash curl "$API/v1/brains/tenant:acme/receipts/01a04729-633a-7086-b63e-b63896318510" \ -H "authorization: Bearer $ACME_KEY" ``` ```json { "data": { "receipt": { "id": "01a04729-633a-7086-b63e-b63896318510", "kind": "context", "consumerId": "01a04729-59eb-7604-a5e7-e2cae0993b51", "key": { "id": "01a04729-59eb-7604-a5e7-e2cae0993b51", "name": "acme copilot" }, "snapshot": { "brainId": "e72iQTc7tg4e7JTDPCvju", "generation": "d008bb06-3bcc-4f65-bdaa-e8bd78ea188d", "stateVersion": "sv_01a04729", "schemaVersion": "nicia-base", "policyVersion": "pol_nicia_open" }, "items": [ { "source": "hubspot:deal:412", "version": 1, "status": "active" }, { "source": "bob-smith", "version": 1 } ], "committedAt": "2026-08-28T06:58:09.849Z" } } } ``` It names which credential read, which sources it was served, and at which version each one stood. `GET /v1/brains/{handle}/receipts/{receiptId}/now` says what has changed since. Store the receipt id next to every answer your product ships. ## 9. Connecting a desktop agent [Section titled “9. Connecting a desktop agent”](#9-connecting-a-desktop-agent) Everything above is the path for **your own agent**, which is HTTP, and it is the path this page recommends. If you also want a desktop client — Claude Code, Codex, Cursor — reading the same Brain over MCP, the mount is on the same handle you already use everywhere else: ```plaintext POST https://api.nicia.ai/mcp/brains/{handle} Authorization: Bearer nbk_... ``` Mint the key the same way you minted every other Brain key in this guide (`POST /v1/brains/{handle}/keys`), point your MCP client’s transport URL at that path, and set its bearer token to the secret. An OAuth access token issued for the same Brain and scope also authenticates here — the desktop client and the header carry the same authority either way. If your agent is code you control, use `/context` and skip MCP entirely. ## What this does not do yet [Section titled “What this does not do yet”](#what-this-does-not-do-yet) Named here so you find them now rather than in week three: * **`/context` reads the whole Brain.** There is no read-time filter; the prompt and the budget are the selection. Narrow with `/query`. * **No temporal reads.** You cannot ask what the Brain said last Tuesday. Receipts are the answer to the historical question: they pin exactly which versions a past read was served. * **`/context` names contested facts** (`warnings[]` kind `contested` plus per-item `status`). That is disagreement in this answer, not store-wide detection. `GET /findings` is the operator inbox. * **Only declared fields are queryable.** A predicate on an undeclared key is a `400` naming it — the endpoint does not scan. `index_not_ready` means a declared field’s index is being rebuilt after its declaration changed. * **One organization is the administration boundary.** Any member of your organization can administer any Brain in it. If you need administrative separation between your own customers, that is one organization per customer today. ## Next [Section titled “Next”](#next) * **[Onboarding: documents to a structured Brain](/docs/tutorials/onboarding/)** — the same surface, with your own model doing the extraction and a human confirming it. * **[Context](/docs/read/context/)** — budgets, warnings, and the `items` format. * **[Handing out keys](/docs/scale/brain-keys/)** — rotation, expiry, and one key per end user. * **[One Brain per customer](/docs/scale/fleet/)** — running many of these at once. # Onboarding: documents to a structured Brain > A customer uploads their materials at signup. Your agent converts them, reads them with a model, and lands typed records a human confirms before anything is true. This is the job: **a customer uploads their materials at signup, you pull out their company description, their ideal customer profiles and their products, and you surface it back for them to confirm.** Most teams build it as a prompt that returns JSON, then discover the hard parts are all downstream — where the JSON lives, how a human corrects it, what happens on the second upload, and how you prove six months later where a field came from. This page builds the whole pipeline. Your agent does the conversion and the model call; the Brain holds the source, the typed records, the confirmation queue and the receipts. Every command was run against a live API before it was published. The shape: ```text customer's file → your converter → POST /documents (the source, verbatim) → your model → POST /batch (typed records, staged) → the customer → GET /changes (confirm) → your product → POST /query (structured, slug-resolved) ``` ## Before you start [Section titled “Before you start”](#before-you-start) A management key, as in [Tutorial 1](/docs/tutorials/agent-company/): ```bash export NICIA_KEY="nsk_…" export API="https://api.nicia.ai" ``` ## 1. Decide the vocabulary first [Section titled “1. Decide the vocabulary first”](#1-decide-the-vocabulary-first) This is the step teams skip, and it is the one that decides whether you have a database or a pile of markdown at the end. The kinds and fields you declare are the same shape you will hand your model as its output schema — write them once, here, and derive the prompt from them. Three kinds is enough for this job. ```bash curl -X PUT "$API/v1/brains/signup:northwind" \ -H "authorization: Bearer $NICIA_KEY" \ -H "content-type: application/json" \ -d '{ "name": "Northwind Robotics", "purpose": "What Northwind told us at signup.", "mode": "reviewed", "schema": { "extends": "nicia-base", "kinds": [ { "name": "company_profile", "description": "The customer company itself, as it describes itself.", "fields": [ { "name": "legal_name", "type": "string" }, { "name": "one_liner", "type": "string" }, { "name": "hq_country", "type": "string" }, { "name": "employee_count", "type": "number" }, { "name": "source_document", "type": "string" } ] }, { "name": "icp", "description": "An ideal customer profile the company sells to.", "fields": [ { "name": "segment", "type": "string" }, { "name": "pain", "type": "string" }, { "name": "titles", "type": "string_list" }, { "name": "source_document", "type": "string" } ] }, { "name": "product", "description": "A product the company sells.", "fields": [ { "name": "product_name", "type": "string" }, { "name": "category", "type": "string" }, { "name": "list_price_usd", "type": "number" }, { "name": "source_document", "type": "string" } ] } ], "links": [ { "name": "sells_to", "from": ["product"], "to": ["icp"] } ] } }' ``` Three choices in there are load-bearing. **`mode: "reviewed"`.** Most writes from your pipeline and agents now stage instead of landing — that is the “surface it back for confirmation” requirement. Your own edits and additive schema still land immediately; destructive changes always wait. It is a property of the Brain rather than of a credential, so it governs your pipeline, your agents, and anything else that ever writes here. **`extends: "nicia-base"`** keeps `person`, `note` and the rest available alongside your three kinds. Omit it only if you want a closed vocabulary, and know that a closed vocabulary with no fieldless kind refuses a write that names no `kind` at all. **`source_document` is a declared field, not magic.** You will want to know which upload a value came from. On this surface a record you write directly cannot cite a document — see [What this does not do yet](#what-this-does-not-do-yet) — so carry the document id as a field you declared yourself. It round-trips, is governed, and is honest about being your bookkeeping rather than the Brain’s. Declared, it is filterable in `/query` like every other declared field. The response tells you the schema was registered under a slug of its own: ```json { "data": { "brain": { "id": "1DnhbTVVavSqqxo9JQDDS", "generation": "da26a9ce-9cee-48ce-9cac-21e81370793a", "handle": "signup:northwind", "name": "Northwind Robotics", "purpose": "What Northwind told us at signup.", "schema": "brain-signup-northwind-7c63abd3", "mode": "reviewed", "createdAt": "2026-08-28T07:00:04.945Z", "updatedAt": "2026-08-28T07:00:04.945Z" } } } ``` If every customer gets the same three kinds — and for an onboarding flow they should — register the vocabulary once for your organization and name it by slug on each create instead of inlining it. See [How schemas work](/docs/schemas/overview/#your-organizations-own-schema). ## 2. Convert the upload, on your side [Section titled “2. Convert the upload, on your side”](#2-convert-the-upload-on-your-side) The customer uploaded a PDF. **The Brain takes text, not bytes**: the document write’s body is a JSON `text` field, and there is no file upload on this surface. Conversion is your step, with whatever you already use — a Markdown converter, a document AI, a parser you own. Two things to get right while you are down there, because they are much more expensive to add later: * **Keep the converted text, not just the extracted fields.** It is what citations resolve to and what a second pass re-reads. * **Keep the original filename and the converter you used** in `metadata`. When an extraction turns out to be wrong, the first question is always whether the conversion was. ## 3. Store the source before anything reads it [Section titled “3. Store the source before anything reads it”](#3-store-the-source-before-anything-reads-it) ```bash curl -X POST "$API/v1/brains/signup:northwind/documents" \ -H "authorization: Bearer $NICIA_KEY" \ -H "content-type: application/json" \ -d '{ "id": "signup:northwind:overview.pdf", "title": "Northwind Robotics — company overview", "text": "# Northwind Robotics — company overview\n\nNorthwind Robotics Ltd is a UK company headquartered in Bristol, employing about 140 people...", "metadata": { "source": "signup-upload", "original_filename": "overview.pdf", "converted_by": "markitdown" }, "extract": false }' ``` ```json { "data": { "document": { "id": "signup:northwind:overview.pdf", "status": "queued" } } } ``` `id` is yours, and the write is an upsert on it, so re-uploading the same file replaces the document rather than duplicating it. Prior versions stay resolvable, which is what keeps a receipt issued last month pointing at the text that was actually shown. ### Why `extract: false` here [Section titled “Why extract: false here”](#why-extract-false-here) Omit `extract` and Nicia runs its own extraction over the document. It is a real pipeline and it works, but it is **not** the one this tutorial wants, and the difference is worth being precise about: Nicia’s built-in extraction writes records of one fixed kind — `statement` — with a fixed statement vocabulary. It does not read the kinds you declared in step 1 and cannot produce a `company_profile` or an `icp`. Extraction into declared kinds belongs to [source snapshots](/docs/write/snapshots/), which this tutorial does not use. So there are two coherent choices and no third: * **`extract: false`, and your model produces your kinds** — this tutorial, and the right answer when you have designed a vocabulary. * **Leave extraction on** and get `statement` records alongside whatever you write, useful when you want the Brain’s own reading of prose and have not committed to a shape. Turning it off is also the difference between one thing to debug and two. ## 4. Your model turns the document into your records [Section titled “4. Your model turns the document into your records”](#4-your-model-turns-the-document-into-your-records) Your prompt, your model, your JSON schema — derived from step 1. The output you want is the body of the batch call: ```json { "records": [ { "id": "northwind-robotics", "kind": "company_profile", "fields": { "legal_name": "Northwind Robotics Ltd", "one_liner": "Warehouse automation that pays for itself in a year.", "hq_country": "United Kingdom", "employee_count": 140, "source_document": "signup:northwind:overview.pdf" } }, { "id": "icp-grocery-distribution", "kind": "icp", "fields": { "segment": "Grocery distribution", "pain": "Chilled picking is punishing and turnover runs above 60% a year.", "titles": ["Supply Chain Director", "Site General Manager"], "source_document": "signup:northwind:overview.pdf" } }, { "id": "gantry-pick", "kind": "product", "fields": { "product_name": "Gantry Pick", "category": "Overhead picking system", "list_price_usd": 240000, "source_document": "signup:northwind:overview.pdf" }, "links": { "sells_to": ["icp-grocery-distribution"] } } ] } ``` Four rules for the model that are about the Brain rather than about prompting: 1. **`id` is a lowercase kebab identifier** — letters and digits joined by single hyphens. Have the model slugify, or slugify yourself; anything else is a `400`. Make the id derivable from the content (`gantry-pick`) so a second pass over a corrected document updates the record instead of adding a second one. 2. **`fields` are values and `links` are edges.** Nicia never infers a relationship from a value that looks like one. A link name must be declared, and its target must already exist — which is why `gantry-pick` and the ICP it points at are in the same batch, where records may reference each other. 3. **Undeclared keys are accepted, stored, and returned** — they are outside the declared vocabulary, but they are not discarded. Let the model emit everything it found: nothing is dropped, and a declared field is governed and returned — and filterable in `/query` — for values already written. An undeclared key is returned but not queryable. See [when a schema is chosen](/docs/schemas/overview/#when-a-schema-is-chosen). 4. **A batch is at most 20 records.** Split a large upload across batches; each one lands or stages as a unit. ## 5. Write it as one batch [Section titled “5. Write it as one batch”](#5-write-it-as-one-batch) ```bash curl -X POST "$API/v1/brains/signup:northwind/batch" \ -H "authorization: Bearer $NICIA_KEY" \ -H "content-type: application/json" \ -H "idempotency-key: onboard:signup:northwind:v1" \ -d @extracted.json ``` ```json { "data": { "records": [{ "id": "northwind-robotics", "kind": "company_profile" }], "change": { "id": "vuKs0FldahMRwkz2lRDRC", "status": "staged", "reason": "brain_mode_reviewed", "records": [ { "id": "northwind-robotics", "kind": "company_profile", "set": { "legal_name": "Northwind Robotics Ltd", "one_liner": "Warehouse automation that pays for itself in a year.", "hq_country": "United Kingdom", "employee_count": 140, "source_document": "signup:northwind:overview.pdf" } } ], "createdAt": "2026-08-28T07:00:05.534Z" } } } ``` **Branch on `change.status`, always** — `applied`, `staged`, or `rejected`. Here it is `staged`, with `reason: "brain_mode_reviewed"` naming why. Nothing is readable yet. Send the `idempotency-key`. A batch has no address of its own, so it is the one write where a retry after a timeout is genuinely ambiguous, and Nicia will not invent a key for you — two batches you meant separately are byte-identical to a retry. Replaying the same key returns the stored outcome: the same change id, not a second staged change. ## 6. Surface it back for confirmation [Section titled “6. Surface it back for confirmation”](#6-surface-it-back-for-confirmation) ```bash curl -G "$API/v1/brains/signup:northwind/changes" \ -H "authorization: Bearer $NICIA_KEY" \ --data-urlencode "status=staged" ``` ```json { "data": { "changes": [ { "id": "vuKs0FldahMRwkz2lRDRC", "status": "staged", "author": { "kind": "key", "id": "01a0472b-2532-7e55-bd56-b0575231d2fd", "name": "Management key" }, "records": [ { "id": "icp-grocery-distribution", "kind": "icp", "after": { "segment": "Grocery distribution", "pain": "Chilled picking is punishing and turnover runs above 60% a year.", "titles": ["Supply Chain Director", "Site General Manager"], "source_document": "signup:northwind:overview.pdf" } } ], "reason": "brain_mode_reviewed", "createdAt": "2026-08-28T07:00:05.553Z" } ] } } ``` That is your confirmation screen, already assembled: every record the model proposed, the values it proposed, and which credential proposed them. Render `records[].after` field by field, and let the customer accept it or send you a correction. One gap to design around: `after` carries **fields, not links**. The `sells_to` edge from step 4 is nowhere in this response — a reviewer confirming from this screen sees the values and not the relationships between them. If relationships matter to your confirmation step, render them from your own extraction output alongside this, rather than from this response. ## 7. What a read says while it waits [Section titled “7. What a read says while it waits”](#7-what-a-read-says-while-it-waits) This is the behaviour that makes a review queue safe to put in front of customers. Ask the Brain a question before anyone has confirmed anything: ```bash curl -X POST "$API/v1/brains/signup:northwind/query" \ -H "authorization: Bearer $NICIA_KEY" \ -H "content-type: application/json" \ -d '{ "kind": "product" }' ``` ```json { "data": { "records": [], "staged": 1, "warnings": [ { "kind": "staged_pending", "count": 1, "detail": "1 change is waiting for review, so the Brain may be waiting on a human rather than knowing nothing." } ], "receipt": "01a0472b-27e0-7f69-95dc-0aafb25851b7" } } ``` Empty — and it says why. “The Brain knows nothing about this customer” and “the Brain is waiting on a human” have opposite fixes, and this is where your product tells them apart. Wire `staged_pending` into the onboarding UI: it is the difference between showing “we could not read your documents” and showing “almost done — confirm what we found”. ## 8. Confirm [Section titled “8. Confirm”](#8-confirm) ```bash curl -X POST "$API/v1/brains/signup:northwind/changes/vuKs0FldahMRwkz2lRDRC/lifecycle/approve" \ -H "authorization: Bearer $NICIA_KEY" \ -H "content-type: application/json" \ -d '{}' ``` The change comes back `applied`. `POST /v1/brains/{handle}/changes/{changeId}/lifecycle/reject` is the other half. Both are also the review inbox in [app.nicia.ai](https://app.nicia.ai), which shows the diff and the source side by side — hand a colleague the URL rather than building a queue UI on day one. ## 9. Now it is structured [Section titled “9. Now it is structured”](#9-now-it-is-structured) Declared fields are preserved, returned, and filterable. Address the ICP by the id you wrote: ```bash curl -X POST "$API/v1/brains/signup:northwind/query" \ -H "authorization: Bearer $NICIA_KEY" \ -H "content-type: application/json" \ -d '{ "kind": "icp", "where": { "slug": "icp-grocery-distribution" } }' ``` ```json { "data": { "records": [ { "id": "icp-grocery-distribution", "kind": "icp", "label": "icp-grocery-distribution", "fields": { "segment": "Grocery distribution", "pain": "Chilled picking is punishing and turnover runs above 60% a year.", "titles": ["Supply Chain Director", "Site General Manager"], "source_document": "signup:northwind:overview.pdf" }, "token": "brain-record-token-v1:3c8b5e7d2a4f6c9b1e3d5a7f0c2b4e6d8a9f2c4b6a1d0e8f735a4c2b9e1d6f0a", "updatedAt": "2026-08-28T07:00:15.415Z" } ], "staged": 0, "warnings": [], "receipt": "01a0472b-542d-7002-9b65-ff5e3497d315" } } ``` Or ask by a field you declared — the same page comes back, from a typed index rather than a scan: ```bash curl -X POST "$API/v1/brains/signup:northwind/query" \ -H "authorization: Bearer $NICIA_KEY" \ -H "content-type: application/json" \ -d '{ "kind": "icp", "where": { "segment": "Grocery distribution" } }' ``` A field you did not declare is refused by name rather than scanned for — a silently empty page would read like “no such ICP”: ```bash curl -X POST "$API/v1/brains/signup:northwind/query" \ -H "authorization: Bearer $NICIA_KEY" \ -H "content-type: application/json" \ -d '{ "kind": "icp", "where": { "region": "EMEA" } }' ``` ```json { "error": { "kind": "validation", "message": "Query predicate field \"region\" is not declared on kind \"icp\". Only declared fields are queryable; declare it in the schema to index it." } } ``` Nothing normalizes text: `"grocery distribution"` does not match. Ranges work on `number`, `date`, and `datetime` fields — see [Search and queries](/docs/read/search/#query--rows). One scope note before you build a dashboard on it: a query addresses **one Brain**. With one Brain per customer, “which of our customers sell to grocery distribution” is a loop over Brains that you run and aggregate — there is no cross-Brain query on this surface. What makes that loop tractable is that every Brain is on the same schema, so the fields you read once mean the same thing in all of them. See [One Brain per customer](/docs/scale/fleet/). ## 10. And it still answers questions [Section titled “10. And it still answers questions”](#10-and-it-still-answers-questions) The same Brain serves prose. Both the original upload and the typed records are in scope, and the citation resolves back to the file the customer gave you: ```bash curl -X POST "$API/v1/brains/signup:northwind/context" \ -H "authorization: Bearer $NICIA_KEY" \ -H "content-type: application/json" \ -d '{ "prompt": "Who does Northwind sell to in grocery, and what do they buy?", "maxTokens": 1200 }' ``` Records render as their label, kind and values, with links in double brackets: ```text gantry-pick (product) product_name: Gantry Pick category: Overhead picking system list_price_usd: 240000 source_document: signup:northwind:overview.pdf sells_to: [[icp-grocery-distribution]] icp-grocery-distribution (icp) segment: Grocery distribution pain: Chilled picking is punishing and turnover runs above 60% a year. titles: Supply Chain Director, Site General Manager source_document: signup:northwind:overview.pdf ``` The citation for the upload names `signup:northwind:overview.pdf` — the id you chose in step 3 — so it round-trips into `GET /v1/brains/{handle}/documents/{documentId}` and into your own “view source” link. ## The second upload [Section titled “The second upload”](#the-second-upload) The pipeline you just built is already re-runnable, and it is worth knowing why before you add machinery you do not need: * The document write is an upsert on your id, so re-converting replaces it. * Record writes are `PATCH`-merge semantics under the batch: fields you send are set, fields you omit are untouched. Re-running your extractor over a corrected document updates the records it still finds and leaves the rest alone. * `change.fields` on the response is what actually moved. An empty array is a no-op, which is the normal case for a re-run and worth counting. * On a `reviewed` Brain, the second pass stages too. That is the intended behaviour — a customer’s confirmation applies to what they confirmed, not to everything the model might say later. What it does **not** do is notice that the second upload contradicts the first. See below. ## What this does not do yet [Section titled “What this does not do yet”](#what-this-does-not-do-yet) * **No file upload.** Documents are `text`. Conversion is yours. * **This tutorial carries the document id as a declared field.** A record write can now cite the document itself instead: send `evidence` with the document id and the exact quote, and the Brain verifies the quote and pins the version ([Records](/docs/write/records/)). The declared field still works; it is bookkeeping you maintain, where `evidence` is provenance the Brain enforces. * **Extraction on `POST /documents` writes statements, not your kinds.** To have Nicia extract into your declared kinds, send the documents as a [source snapshot](/docs/write/snapshots/); its plan shows each value with the quote behind it before anything lands. * **The confirmation preview shows fields, not links.** * **Sources that disagree show up on `/context`.** Two uploads that disagree both land; the read names it as `warnings[]` kind `contested` plus per-item `status`. That is disagreement in this answer, not store-wide detection. * **Extraction progress is not observable on this surface.** If you do leave it on: the write acknowledges `queued` identically whether extraction was requested or not, the document’s `status` tracks text indexing rather than extraction, and `GET /v1/brains/{handle}/documents/{documentId}/records` answers an empty list for “still running”, “found nothing”, and “failed” alike. An extraction that fails after it was scheduled reports nowhere. Running the model yourself, as this page does, is also the only way to know it ran. ## Next [Section titled “Next”](#next) * **[Build an agent company on Nicia](/docs/tutorials/agent-company/)** — the read side, at scale, one Brain per customer. * **[Custom schemas](/docs/schemas/custom/)** — field types, evolution, and bringing a model you already have. * **[Review](/docs/govern/review/)** — the confirmation queue in depth. * **[Records](/docs/write/records/)** — merge semantics, concurrency, and links. # Documents > Push text, markdown, and transcripts into a Brain with whatever metadata you already have. No schema step. A document is any prose you want the Brain to know: a call note, a support thread, a wiki page, a commit message. Send it and it becomes searchable and citable immediately. ```bash curl -X POST "$API/v1/brains/tenant:acme/documents" \ -H "authorization: Bearer $NICIA_KEY" \ -H "content-type: application/json" \ -d '{ "id": "zendesk:ticket:8821", "title": "SSO login loop after IdP change", "text": "Customer reports a redirect loop after switching to Okta…", "metadata": { "source": "zendesk", "status": "resolved", "priority": "p1" }, "occurredAt": "2026-08-02T14:20:00Z" }' ``` ```json { "data": { "document": { "id": "zendesk:ticket:8821", "status": "queued" } } } ``` Only `text` is required. Everything else is optional and useful. **The Brain takes text, not bytes.** The write’s body is a JSON `text` field and there is no file upload, so a PDF, a slide deck, or an audio recording is yours to convert first. Conversion is your step; keep the original filename and the converter you used in `metadata`. Keep the document’s headings as markdown headings when you convert: a standing answer cites a document by section (`§ `), and text with no headings can be cited only by line range. ## Your id, not ours [Section titled “Your id, not ours”](#your-id-not-ours) `id` is a string you choose, and it is the document’s permanent handle. Re-send the same `id` and you replace that document. **Send one.** A document with your id is an upsert, which makes it naturally idempotent — a retry after a timeout is one document, not two. Omit it and Nicia mints an id, but then a retry needs an `Idempotency-Key` or you get duplicates. See [Idempotency](/docs/api/idempotency/). Use the id from the system the content came from — `zendesk:ticket:8821`, `notion:page:abc`. Then a nightly sync is a loop of `POST`s with no bookkeeping, no diffing, and no “have I sent this already” table. ```bash # Same id, new content — replaces version 1 curl -X POST "$API/v1/brains/tenant:acme/documents" \ -H "authorization: Bearer $NICIA_KEY" \ -H "content-type: application/json" \ -d '{ "id": "zendesk:ticket:8821", "text": "…updated transcript…" }' ``` Nicia keeps prior versions. A receipt issued last month still names the version it was served, and the key that made that read can still fetch [the text that version held](/docs/govern/receipts/#the-text-a-receipt-pinned). ## Metadata is free-form, and nothing drops it [Section titled “Metadata is free-form, and nothing drops it”](#metadata-is-free-form-and-nothing-drops-it) `metadata` takes any flat JSON object. Nothing needs declaring and nothing is discarded — every key you send comes back on the document when you read it. The one refusal: keys beginning `nicia:` are reserved — that is where the server keeps the document’s own `title` and `occurredAt` — so writing one is a `400`, never a key that is silently dropped. ```bash curl "$API/v1/brains/tenant:acme/documents/zendesk:ticket:8821" \ -H "authorization: Bearer $NICIA_KEY" ``` What metadata is **not** is an index. No read filters on it: `/context` and `/search` take no filter at all, and `/query`’s `where` index-resolves exact `slug` equality only — free-form document metadata is never a predicate. Metadata is how a value survives the trip; it is not how you find it again. So send it freely — source system, document type, author, team, status, language, confidentiality. Wanting to govern one of them is the signal that it belongs [declared](/docs/schemas/custom/) as a field on the record kind it describes (returned and governed; non-slug filtering still waits on a property index). Resolve by slug today: ```bash curl -X POST "$API/v1/brains/tenant:acme/query" \ -H "authorization: Bearer $NICIA_KEY" \ -H "content-type: application/json" \ -d '{ "kind": "ticket", "where": { "slug": "zendesk-ticket-8821" } }' ``` A declared field governs and returns everything already written, because nothing you sent was thrown away. The declaring itself happens when the Brain is created — see [when a schema is chosen](/docs/schemas/overview/#when-a-schema-is-chosen). A declared field is also filterable in `/query`; an undeclared one is returned but not queryable. ## Checking on a document [Section titled “Checking on a document”](#checking-on-a-document) A document can take a moment to index. The write answers `queued` — the bytes are stored and the document is already addressable by your id — and a read reports `processing` until body search can reach it, then `indexed`. A document the Brain refused is `rejected`, with the reason on it, which in a batch is how one bad document reports itself while the rest land. Read it back to check: ```bash curl "$API/v1/brains/tenant:acme/documents/doc_91ab" \ -H "authorization: Bearer $NICIA_KEY" ``` ```json { "data": { "document": { "id": "doc_91ab", "status": "indexed", "version": 1, "createdAt": "2026-08-14T09:41:02Z", "updatedAt": "2026-08-14T09:41:05Z" } } } ``` ## Listing what you sent [Section titled “Listing what you sent”](#listing-what-you-sent) `GET /v1/brains/{handle}/documents` pages through every document you submitted to the Brain over the API, newest version of each, keyset paged by document id. It is a listing of **submissions**, not an inventory of everything the Brain can answer from. Content that arrived another way — a synced folder or URL source, say — has no submission behind it and no document id to address, so it is not here, but `/context`, `/query`, and `/search` all read it. Use the listing to reconcile your own pipeline against what landed; use [`/context`](/docs/read/context/) to see what the Brain will actually answer with. ## Batches [Section titled “Batches”](#batches) Send up to 100 documents in one request. Each is indexed independently, so one bad document does not fail the rest: ```bash curl -X POST "$API/v1/brains/tenant:acme/documents" \ -H "authorization: Bearer $NICIA_KEY" \ -H "content-type: application/json" \ -d '{ "documents": [ { "id": "zendesk:ticket:8821", "text": "…" }, { "id": "zendesk:ticket:8822", "text": "…" } ] }' ``` ```json { "data": { "documents": [ { "id": "zendesk:ticket:8821", "status": "queued" }, { "id": "zendesk:ticket:8822", "status": "rejected", "error": "text is empty" } ] } } ``` Per-item isolation is a batch’s promise and only a batch’s. A single-document write that the Brain refuses fails with the refusal’s own status — never a `201` whose body reports a document that was not stored. ## What happens next [Section titled “What happens next”](#what-happens-next) An indexed document is immediately available to [`/context`](/docs/read/context/) and [`/search`](/docs/read/search/). That is the whole requirement — you can stop here and have a working retrieval system. Separately, Nicia can turn what it recognises in prose into proposed structured changes, each anchored to the exact passage it came from. In `open` mode those land directly; in `reviewed` mode they queue for approval. Either way the document itself was already useful. ```bash # See what was extracted from a document curl "$API/v1/brains/tenant:acme/documents/zendesk:ticket:8821/records" \ -H "authorization: Bearer $NICIA_KEY" ``` That answers the record **addresses** this document produced and what kind each one is — not their values. Every read that returns values returns them under a receipt, so read the records themselves through [`/context`](/docs/read/context/) or [the record read](/docs/write/records/#reading-a-record-back). Turn extraction off per document when you only want the text searchable: ```json { "id": "raw:log:5512", "text": "…", "extract": false } ``` ## Superseding [Section titled “Superseding”](#superseding) Documents are addressed, so the way to replace one is to write it again under the same `id`. The new text becomes the current version, the old version is kept, and a receipt that cited the old version still names it — its text stays readable through that receipt. There is no document delete, and a document written here is never retired: it is served until you write it again. Removing its influence means retiring every record extracted from it and marking every receipt that cited it — that is correction work over the accepted state, not a delete, and Nicia would rather have no operation than one that quietly leaves derived records standing. When the content you send is revised as a whole set — one customer’s documents, say — send it as a [source](/docs/write/sources/) instead. A `complete` [snapshot](/docs/write/snapshots/) that leaves a document out proposes retiring it, through the Brain’s review policy, together with the record values only that document supported. A retired document stops being served; its versions are kept, and the receipts that pinned them still resolve. ## Next [Section titled “Next”](#next) * **[Records](/docs/write/records/)** — the typed on-ramp. * **[Context](/docs/read/context/)** — getting this back out. # Branches and Proposals > Work on an isolated branch, compare it with main, and pin a reviewable Proposal. Preview — branches and Proposals may change without notice These operations work today, but their shapes may change or be removed. See [Stable and preview](/docs/api/stability/). A branch is an isolated working copy of a Brain. Give an agent a [branch-scoped key](/docs/scale/brain-keys/#an-agent-working-on-one-branch) to let it write records there without granting access to main. Main’s state does not change when the branch changes. Read that branch with the `branch` coordinate on `/context`, `/search`, or `/query`. Documents submitted with `branch` and `extract: false` are pinned as staged evidence to that branch and remain invisible on main. Compare includes document versions alongside record changes. A Proposal stages the combined delta; approval and publication through the ChangeSet lifecycle make its selected content visible on main. Branch reads and their [receipts](/docs/govern/receipts/) stay scoped to the branch head they served. Reads never wait on a branch’s writer. Any number of `/context`, `/search`, `/query`, document, receipt and compare reads run on one branch at the same time, and beside a write to it. Each read is served from one committed branch head and its receipt names that head. ## A read-only branch [Section titled “A read-only branch”](#a-read-only-branch) Create a branch with `mode: "read_only"` to pin it at its fork point: ```json { "mode": "read_only" } ``` `POST /v1/brains/{handle}/branches` with that body forks the current main state exactly as a writable branch does, then refuses every write to it with `409`: records, documents, and opening a Proposal (it has nothing to publish). A key scoped to it can hold only `read`; minting or widening one with more is a `409`. It can still be read by any number of callers at once, compared with main, archived, and destroyed. The mode is fixed at creation; retrying the same `Idempotency-Key` with a different mode is a `409`. Use it as the pinned view for an evaluation: point the evaluation’s reads at the read-only branch and re-run them later against the knowledge as it was when the branch was created, while main keeps changing. ## Archive, restore, destroy [Section titled “Archive, restore, destroy”](#archive-restore-destroy) `POST /v1/brains/{handle}/branches/{branchId}/archive` stops everything a branch does. It refuses reads and writes, emits no `date.reached` events, and runs no index rebuilds. Its data is kept. `POST /v1/brains/{handle}/branches/{branchId}/restore` resumes it: date crossings that came due while it was archived fire on the next sweep, and parked rebuilds continue. `DELETE /v1/brains/{handle}/branches/{branchId}` destroys an archived branch. In the same transaction that records the destruction, it removes everything the fork and the branch’s writes created: record and typed-field indexes, pending and emitted date crossings, document pins, staged documents nothing else references, read watermarks and cursors. It also closes the branch’s open Proposals. Nothing about the branch fires or answers afterwards. The Brain’s event history, read receipts, and the closed Proposals remain. Retrying the delete is safe. ## Compare and open [Section titled “Compare and open”](#compare-and-open) `GET /v1/brains/{handle}/branches/{branchId}/compare` returns the branch’s changes since it forked, applied onto main as main is now. It stages nothing, records no admission decision, and waits on no writer. Run it whenever you need a fresh preview. Compare is three-way. Changes main made after the fork are not reported as branch changes, so they are never reverted. Each record and document is compared at three points: main at the fork, main now, and the branch. * A record the branch changed and main did not is a change. * If the branch and main changed different fields of the same record, they merge: the proposal keeps main’s new values and adds the branch’s. * If both changed the same field to different values, the record is a conflict. * If main deleted a record the branch changed, or both created a record with the same id, the record is also a conflict. * A document follows the same rules for its served version. If the branch pins a new version and main now serves a different one, the document is a conflict. ```json { "data": { "summary": { "nodes": { "upserts": 1, "deletions": 0 }, "edges": { "upserts": 0, "deletions": 0 }, "conflicts": 1 }, "diff": { "records": [ { "id": "gamma", "kind": "company", "operationId": "3121a944-e1a0-81d3-aadb-e2d489e867bd", "main": "changed", "conflict": true, "changes": [ { "part": "field", "field": "name", "fork": "Gamma Co", "main": "Gamma Co (main)", "branch": "Gamma Co (branch)", "conflict": true } ] } ], "documents": [] } } } ``` `main` says what main did to the record since the fork: `unchanged`, `changed`, `deleted`, or `created`. Each entry in `changes` is a field (or `part: "body"`) the branch changed. It lists main’s value at the fork, main’s value now, and the branch’s value. A value is omitted when that side does not have the field. A document entry lists the version the branch pins (`versionId`), the version main served at the fork (`forkServed`), and the version main serves now (`expectedServed`). Publication requires main to still serve `expectedServed`. `POST /v1/brains/{handle}/proposals` with a branch source pins that delta as a Proposal and stages a review ChangeSet: ```json { "source": { "kind": "branch", "branchId": "branch-id" }, "intent": "Update the company catalog" } ``` The response gives the Proposal and its first immutable revision. The revision includes `changeIds` and the pinned diff, which has the same `records` and `documents` that compare returns. You can open a Proposal that has conflicts. Each operation waits in review until someone approves it. A conflicting operation carries the branch’s value, and its review item names main’s value at the fork, main’s value now, and the branch’s value. To publish only the clean changes, `select` their `operationId`s and leave the conflicts as the remainder. The conflicts stay pending until a reviewer approves or rejects them. Approving one publishes the branch’s value over main’s. `GET /proposals` lists them; filter with `sourceKind` or `status`. `GET /proposals/{id}` returns every revision and the ChangeSets inside it. A branch-scoped key sees only Proposals from its own branch. When the branch advances, `POST /proposals/{id}/revise` pins its new delta in a new revision. `POST /proposals/{id}/close` closes an open Proposal without applying it. Applying still uses the existing ChangeSet `select`, `approve`, and `publish` operations under main’s policy. A Proposal creates no shortcut to accepted state. Direct agent writes staged by admission and source snapshot reviews also appear as Proposals with sources `direct` and `snapshot`. `proposal.opened`, `proposal.revised`, `proposal.applied`, and `proposal.closed` appear on the [event stream](/docs/govern/events/) and can be delivered to a webhook. The Brain MCP endpoint offers read-only Proposal listing and inspection. # Records > Push structured data into a Brain. Send what is now true; Nicia works out what changed. Not everything you know is prose. A record is a thing you can filter and sort on — optionally typed as a person, an organization, an event. ```bash curl -X PATCH "$API/v1/brains/tenant:acme/records/bob-smith" \ -H "authorization: Bearer $NICIA_KEY" \ -H "content-type: application/json" \ -d '{ "kind": "person", "fields": { "name": "Bob Smith", "role": "Head of Platform", "email": "bob@acme.example" }, "links": { "works_at": ["acme"] } }' ``` ```json { "data": { "record": { "id": "bob-smith", "kind": "person", "undeclared": ["email", "name", "role"], "token": "brain-record-token-v1:9f2c4b6a1d0e8f735a4c2b9e1d6f0a3c8b5e7d2a4f6c9b1e3d5a7f0c2b4e6d8a", "updatedAt": "2026-08-14T09:14:02.118Z" }, "change": { "id": "chg_44a1e0", "status": "applied", "fields": ["role"], "records": [ { "id": "bob-smith", "kind": "person", "set": { "role": "Head of Platform" } } ], "createdAt": "2026-08-14T09:14:02.118Z" } } } ``` A write answers **what it did**, not what the record holds. You get the address, the kind it is filed under, the opaque `token` to send back as `If-Match` (also returned as the response `ETag`), which of your fields moved, and — in `undeclared` — the ones you sent that are not declared yet. You do not get the record’s values back, including the ones you just sent: a write key can hold `write` without `read`, and values leave a Brain only under a [receipt](/docs/govern/receipts/). Read them through [`/context`](/docs/read/context/), [`/query`](/docs/read/search/), or the record read. A body that states no `kind`, `label`, `fields`, or `links` is a `400`. There is nothing to write in it, and it is not a way to read the record back. A Brain you created thirty seconds ago with no schema step still accepts this. Every Brain starts on `nicia-base`, which declares the `person` kind and the `works_at` link but **no fields at all** — so every field in the write above, `name` included, is stored, returned, and named in `undeclared` rather than refused. That is why `undeclared` lists all three: it names what is outside the governed vocabulary, not what is unwelcome. Declaring one later makes values that were already there governed, returned, and filterable in `/query`; an undeclared field is not queryable. See [Schemas](/docs/schemas/overview/). Fields are the part nothing rejects. A `kind` and a link name are vocabulary: one your Brain’s schema does not declare is a `400` naming what it does declare, and so is a link to a record the Brain does not hold yet — see [Links](#links). **`kind` is optional too.** Omit it and the record is filed under your schema’s free-form kind — `note` on both built-ins, which is what it reads back as — rather than under a kind you never chose. Naming a kind later is a retype and never changes the record’s address, so nothing you built against it breaks. ## The id is yours [Section titled “The id is yours”](#the-id-is-yours) `bob-smith` in the path is the record’s identity, and it is a string you choose. Use the id you already have — `user-4471`, a lowercase UUID, `crm-account-4471` — and writes become idempotent for free: the address is the identity, so sending the same body twice is one write. An id is a lowercase kebab identifier: letters and digits, joined by single hyphens. That is the address the record is stored under, so an id outside the rule is a `400` rather than a record you cannot reach later. If your own id has other punctuation — `crm:account:4471` — lowercase it and join it with hyphens once, at your edge, and it is still a pure function of the id you already hold. ## Send what is true, not what changed [Section titled “Send what is true, not what changed”](#send-what-is-true-not-what-changed) `PATCH` merges: the fields you send are set, the fields you omit are left alone. Nicia computes the diff, so your sync job never has to read before it writes. ```bash # Bob got promoted. You know one field. Send one field. curl -X PATCH "$API/v1/brains/tenant:acme/records/bob-smith" \ -H "authorization: Bearer $NICIA_KEY" \ -H "content-type: application/json" \ -d '{ "fields": { "role": "VP Platform" } }' ``` The response’s `change.fields` tells you what actually moved — an empty array means your write was a no-op, which is the normal case for a nightly sync and worth counting. Use `PUT` when you are the authority on the whole record and the declared fields you omit should be cleared: ```bash curl -X PUT "$API/v1/brains/tenant:acme/records/bob-smith" \ -H "authorization: Bearer $NICIA_KEY" \ -H "content-type: application/json" \ -d '{ "fields": { "name": "Bob Smith", "role": "VP Platform" } }' ``` To clear one field with `PATCH`, send it as `null`. `PUT` clears what your **schema declares** and you omitted — so on a Brain whose schema declares no fields, it clears nothing. That includes the default: `nicia-base` declares no fields, so the `PUT` above on a fresh Brain leaves `email` exactly where it was and is indistinguishable from the `PATCH` before it. `PUT` becomes the destructive verb only once a schema declares the fields you mean to be authoritative over. Until then, clear a field the way `PATCH` does — send it as `null`. Frontmatter the schema does not declare — a `source_url` a markdown import left behind, an `imported_by` a colleague wrote — survives untouched for the same reason and always will, because it is stored data no response on this surface shows you, and a write should not delete what it never let you see. Because the address is the identity, `PUT` and `PATCH` are naturally idempotent: sending the same body twice is one write, and the second answers `change.fields: []`. That covers a retry of an UPDATE completely. It cannot cover a create whose answer you never received — nothing is at the address either way — so send an `Idempotency-Key` on a create if a duplicate would cost you something, on any write verb. Nicia will not invent one for you: the only key it could derive is a hash of your request, and two writes you meant separately are byte-identical to a retry. See [Idempotency](/docs/api/idempotency/). ## Links [Section titled “Links”](#links) Relationships go in `links`, separately from `fields`: ```json { "fields": { "name": "Bob Smith", "team": "support/billing" }, "links": { "works_at": ["acme"] } } ``` `fields` are values and `links` are edges, always. Nicia never infers a relationship from what a value looks like — `"support/billing"` above is a string, and stays one, because `team` is a field and not a declared link. Only names your schema declares as links are accepted in `links`; anything else is a `400`, and so is a target the Brain does not hold yet — an edge is written against both endpoints, so a link to a record that does not exist would be an edge that silently never materializes. Write the target first, or send both in one [`POST /batch`](/docs/write/records/#batches), where records may reference each other. An edge you send replaces the whole set on that name, so `PATCH` with `"links": { "works_at": [] }` removes the relationship, and `PATCH` with a different target repoints it. A `PUT` removes every link name it does not send — all of them, with no undeclared exception, because `links` only ever accepts names your schema declares. You never name the old target — the Brain reads what the record currently holds and writes the difference. ## Cite your sources [Section titled “Cite your sources”](#cite-your-sources) When a value comes from a document the Brain already holds, say which one. `evidence` names the document, the words in it that ground the write, and your own locator for where they are: ```json { "kind": "finding", "fields": { "status": "open", "summary": "Site lies on a migratory flyway." }, "evidence": [ { "document": { "id": "environmental-report" }, "quote": "The project area lies within the Pacific Flyway.", "locator": { "page": 14, "section": "4.2" } } ] } ``` The quote is checked, not trusted: it must occur in the document’s current version, where only whitespace may differ — a wrapped line or a table cell’s padding still matches, and so does a sentence that continues across a rendered page break (a form feed and one running header or footer line); a paraphrase does not. A quote the document does not contain, or a document the Brain does not serve, is a `400` and nothing is written. A document waiting in review is not served, so it cannot be cited yet, and a quote that continues across a page break must match as one run of text, so a form feed or running footer inside it makes it fail. For a document of a [named source](/docs/write/sources/), add its `source` next to the `id` you gave it there. To rest a value on a part of the document rather than on a sentence, add an `anchor`: `§ ` for everything under a heading (a longer path, `§ Parent › Child`, disambiguates), or `L12-L14` for a line range of the stored text. The Brain resolves it over the version it serves and refuses one that names no heading, matches more than one, or does not contain your `quote`. The response and later reads show it resolved to its full heading path. A `statement` that cites an anchor is monitored: a [standing answer](/docs/read/context/#an-answer-your-own-agent-maintains) that depends on it is flagged when that part of the document changes. Cite one anchored part per statement. A `statement` that cites one document and sets its own `sourceAnchor` field is treated as if it had sent that as the `anchor`. ```json { "kind": "statement", "fields": { "body": "A client qualifies only above two million in revenue." }, "evidence": [ { "document": { "id": "frameworks/qualification.md" }, "anchor": "§ Qualification", "quote": "annual revenue exceeds two million dollars" } ] } ``` Each citation pins the version of the document it quoted, so revising the document later does not change what an earlier citation says. It does not hide the revision either: once the document no longer contains the cited words, or is withdrawn, reads serve the record with [`support`](/docs/read/context/#a-record-whose-cited-document-moved) saying so. The next write that cites it must quote the version the Brain serves then. In a [batch](#batches), each record keeps its own citations. At most 10 per record, 2,000 characters per quote, and 16 locator keys; `startByte`, `endByte`, `sourceAnchor` and `anchorHash` are the Brain’s own and cannot be sent. Reads return what you cited on the record’s `evidence`, and a `/context` citation for the record carries the same list, so an answer shows the passage behind each value. A write that waits in review keeps its citations, and they carry into the record once a person approves and publishes it. A citation counts as grounding only when it quotes the document and someone else wrote the version it quotes: a person, or a key under a different name. A key that writes a document and then cites it is still asserting the value on its own word, so the Brain’s policy treats the write as it treats one with no citation, and a reviewed Brain stages it with the reason `brain_policy_self_cited_agent_stake`. A citation with no `quote` names a document without showing that it says anything about the value, so it never counts as grounding either. Both kinds of citation are still stored and served. ## Records without a kind [Section titled “Records without a kind”](#records-without-a-kind) You do not have to know what a record is before writing it. Omit `kind`, keep your own stable `id`, and send the fields you already have. The record is filed under your schema’s free-form kind — `note` on both built-ins — and is addressable and readable straight away; name a kind later when you need declared fields for governance, returned shape, and `/query` filters. Its address does not change. ## Batches [Section titled “Batches”](#batches) When several writes must land together — or not at all — send them as one batch: ```bash curl -X POST "$API/v1/brains/tenant:acme/batch" \ -H "authorization: Bearer $NICIA_KEY" \ -H "content-type: application/json" \ -H "idempotency-key: sync:acme:2026-08-14" \ -d '{ "records": [ { "id": "acme", "kind": "company", "fields": { "name": "Acme", "domain": "acme.example" } }, { "id": "bob-smith", "kind": "person", "fields": { "name": "Bob Smith" }, "links": { "works_at": ["acme"] } } ] }' ``` ```json { "data": { "records": [ { "id": "acme", "kind": "company", "token": "brain-record-token-v1:3c8b5e7d2a4f6c9b1e3d5a7f0c2b4e6d8a9f2c4b6a1d0e8f735a4c2b9e1d6f0a" }, { "id": "bob-smith", "kind": "person", "token": "brain-record-token-v1:9d1a7c0e5b3f2a4c6e8d0b1f3a5c7e9d2b4f6a8c0e1d3b5f7a9c2e4d6f8b0a1c" } ], "change": { "id": "chg_5f21b8", "status": "applied", "createdAt": "2026-08-14T09:20:11Z" } } } ``` A batch is validated and applied as one unit: everything lands, everything stages, or everything is rejected. Records in the same batch can link to each other. `records` echoes each written record in request order with the `token` the Brain accepts there now — absent where a staged create left nothing accepted — so the next conditional write needs no re-read. A batch carries at most 20 records, and an update counts for more than a create: a record Nicia does not hold yet is one entry in the canonical change, while updating one costs an entry per field that actually changes and per link added or removed, plus one when its `kind` moves, against a ceiling of 100 entries and 2 MiB per batch. A batch that crosses either ceiling is rejected whole, naming the ceiling it crossed, so a partial write is never the thing you have to detect. For a bulk load, send several batches — or, when the records are a list you keep elsewhere (a CSV of leads, an account export), add it to a source snapshot [as a table](/docs/write/snapshots/#add-a-table): each row becomes a record, and the next upload of the list retires the rows it no longer holds. The same ceiling applies to a single `PATCH` or `PUT`, because it is a ceiling on entries and not on records: updating a hundred-and-twenty fields of one record crosses it on its own. Split that write across requests — it is the one case where sending fewer records is no help at all. ## Reading a record back [Section titled “Reading a record back”](#reading-a-record-back) ```bash curl "$API/v1/brains/tenant:acme/records/bob-smith" \ -H "authorization: Bearer $NICIA_KEY" ``` ```json { "data": { "record": { "id": "bob-smith", "kind": "person", "fields": { "name": "Bob Smith", "role": "VP Platform" }, "links": { "works_at": ["acme"] }, "token": "brain-record-token-v1:3c8b5e7d2a4f6c9b1e3d5a7f0c2b4e6d8a9f2c4b6a1d0e8f735a4c2b9e1d6f0a", "updatedAt": "2026-08-14T09:20:11Z" }, "staged": 0, "warnings": [], "receipt": "rcp_019fff51" } } ``` When a record’s values came from a document — extracted, or written with [`evidence`](#cite-your-sources) — `sources` names the ids of the documents behind them, one array per record, so each one round-trips into [`GET /documents/{documentId}`](/docs/write/documents/), and `evidence` carries each citation’s quote and locator. `bob-smith` here was written directly through `PATCH` with no citation, so both are absent. `links` comes back the way you sent it — relationships are their own key, never mixed into `fields`, and never inferred from a value that happens to look like one. This is exactly what [`POST /query`](/docs/read/search/) returns for this record — one record instead of a page, same projection — so it is a read like any other: receipted, with `staged` and `warnings[]` saying whether the answer is the whole truth. Keep the `receipt` and [`GET /receipts/{receiptId}`](/docs/govern/receipts/) will tell you which version you were shown. An address the Brain holds nothing at is a `404`. ## Who said a value [Section titled “Who said a value”](#who-said-a-value) Every accepted write records who said each field’s value: a person, an agent, an API key, or a source. Ask a field for its look-behind: ```bash curl "$API/v1/brains/tenant:acme/records/acme/fields/status" \ -H "authorization: Bearer $NICIA_KEY" ``` ```json { "data": { "recordId": "acme", "field": "status", "status": "present", "value": "contacted", "support": [ { "sourceId": "internal:user:ana", "producer": { "kind": "human", "id": "ana" }, "recordedAt": "2026-09-27T10:04:00.000Z" } ], "dissent": [ { "value": "new", "sources": [ { "sourceId": "internal:agent:crm-sync", "producer": { "kind": "agent", "id": "crm-sync" }, "recordedAt": "2026-09-27T09:00:00.000Z" } ] } ], "history": [ { "op": "assert", "value": "new", "sourceId": "internal:agent:crm-sync", "producer": { "kind": "agent", "id": "crm-sync" }, "recordedAt": "2026-09-27T09:00:00.000Z" }, { "op": "assert", "value": "contacted", "sourceId": "internal:user:ana", "producer": { "kind": "human", "id": "ana" }, "recordedAt": "2026-09-27T10:04:00.000Z" } ], "receipt": "rcp_019fff52" } } ``` The response has four parts: * **`value`** is what the field holds now. * **`support`** lists the producers whose latest word on the field is that value. * **`dissent`** lists the other values producers still assert, and who asserts each. Here, the CRM’s value lost to a person’s edit. * **`history`** is every assertion and retraction, in the order they were recorded. A change the Brain published through Review names its `changeId`. Add `asOf` (an ISO instant) to answer as the Brain had recorded the field at that moment. `@kind` and `@body` are the record’s kind and a hash of its prose, so a retype or a prose edit is attributed too. Records accepted before field support was recorded name `internal:system:legacy`. Two limits on what a producer count means: * **It is not independence.** Two keys that sync from one system are two producers, and nothing here claims they are independent. * **A person’s value is protected.** An agent’s write that would change a value a person set waits for review. Agents update each other’s values freely. So does a person’s deletion: an agent’s write that would create a record a person deleted waits for review instead of bringing it back. * **A decided proposal is not asked twice.** When an agent re-sends a value a reviewer already rejected, the write answers with that rejected change (`status: "rejected"`, `reason: "already_rejected: …"`) and nothing is staged. When the same value is already waiting, it answers with the waiting change (`status: "staged"`, `reason: "already_staged: …"`). A different value, the same value from another agent, or the same value re-sent with different evidence (another cited document, passage or quote) is a new proposal. In a batch, only the repeated proposals are set aside; the rest of the batch goes through. * **A key’s id is its identity; its name is only the label shown.** A write made with a Brain key is attributed to that key’s id, not its name. Two keys with the same name, including a revoked key and its replacement, are different producers in look-behind and in history. Read the source id from look-behind rather than guessing it. ## Create vs update [Section titled “Create vs update”](#create-vs-update) **Create** — the Brain holds nothing at this address yet. Send your fields (and optionally `kind`, `label`, `links`). Omit `If-Match`; there is no token to pin. **Update on the records plane** — the Brain already holds a record at this address. Send only what changed; Nicia merges under `PATCH` or replaces declared fields under `PUT`. Pin the token you read last as `If-Match` — from a prior `GET` (its body `token` or its `ETag` header), from the `record.token` on a write response, or from a receipted read. A stale or cross-branch token is `409 conflict`. Re-read, re-apply your delta, resend. **Update on a batch** — `POST /batch` takes an optional `ifMatch` on each record: the `token` you last read for it. Every precondition is checked before anything is written, so one stale token refuses the whole batch with `409 conflict` naming that record, and nothing is applied. A record that does not exist yet cannot carry a token. The response returns each written record’s new `token`, so the next batch chains without a re-read. A malformed `ifMatch` is `400 validation`. See [Concurrency](#concurrency). ## Concurrency [Section titled “Concurrency”](#concurrency) Writes are last-write-wins by default, which is what a sync job wants. When you need optimistic concurrency, send back the record’s opaque `token` as `If-Match` (a read’s `ETag` is the same value): ```bash curl -X PATCH "$API/v1/brains/tenant:acme/records/bob-smith" \ -H "authorization: Bearer $NICIA_KEY" \ -H 'if-match: "brain-record-token-v1:3c8b5e7d2a4f6c9b1e3d5a7f0c2b4e6d8a9f2c4b6a1d0e8f735a4c2b9e1d6f0a"' \ -H "content-type: application/json" \ -d '{ "fields": { "role": "CTO" } }' ``` The token is opaque and branch-scoped: it identifies the Brain generation, the active branch and its incarnation, the record and its incarnation, and the editable revision. A token read on one branch never authorizes a write on another, and `A → B → A` edits or delete/recreate do not revive an old token. A stale or cross-branch token is `409 conflict`; a malformed header — an integer, an unquoted value, a weak tag, the `*` wildcard, or a list — is `400 validation` naming the header rather than a precondition that quietly did nothing. Re-read, re-apply, resend. `PATCH`, `PUT`, and `DELETE` all honour the header; a batch takes the same token as `ifMatch` on each record. ## When writes wait [Section titled “When writes wait”](#when-writes-wait) In `reviewed` mode the response is the same shape with a different status: ```json { "data": { "records": [{ "id": "acme", "kind": "company" }], "change": { "id": "chg_44a1e0", "status": "staged", "createdAt": "2026-08-14T09:22:47Z" } } } ``` Branch on `change.status`, always: `applied`, `staged`, or `rejected`. A rejection is a `200` — the request was well-formed and the Brain decided against it. See [Review](/docs/govern/review/). ## Next [Section titled “Next”](#next) * **[Schemas](/docs/schemas/overview/)** — the five tiers and what each buys. * **[Context](/docs/read/context/)** — reading it all back. # Snapshots > Replace a source's whole set of documents, see what would change before anything is served, and decide it under your Brain's policy. Preview — sources and snapshots may change without notice Every source and snapshot operation is preview: it works, but its shape may change or be removed without notice. See [Stable and preview](/docs/api/stability/). A **snapshot** is one [source’s](/docs/write/sources/) current set of documents — all of it, or an explicitly partial part. It is the operation for “here is everything this customer gave us now”: you send the set, the Brain compares it with what the source said before and with what the Brain has accepted, and you get a **plan** before anything changes what a read serves. Nothing a snapshot holds is served — by `/context`, `/search`, `/query` or the document listing — until you apply its plan, and applying goes through the Brain’s own [admission policy](/docs/govern/review/), so a document that disappeared from the set is retired only through a decision. The journey: 1. **Open** a snapshot of the source. 2. **Add** its documents, up to 100 per request — or [a table](#add-a-table), one record per row. 3. **Seal** it as `complete` or `partial`. 4. **Poll** until it is `planned`. 5. **Read the plan** — what each document changes — and **the findings** — where the snapshot’s own documents disagree. 6. **Apply** the plan revision you read. 7. **Review** what the policy held back. 8. Every read after that is **receipted** with the exact versions it served. When apply stages a review change, it opens a `snapshot` [Proposal](/docs/write/proposals/). The apply response names it as `review.proposalId`. Inspect that Proposal to see its pinned diff and member ChangeSet, then use the existing review lifecycle to decide what reaches main. ## Open a snapshot [Section titled “Open a snapshot”](#open-a-snapshot) ```bash curl -X POST "$API/v1/brains/tenant:acme/sources/acme-content/snapshots" \ -H "authorization: Bearer $NICIA_KEY" \ -H "idempotency-key: acme-content-2026-09-23" ``` ```json { "data": { "snapshot": { "id": "01a0cdbb-66e1-75be-a056-c758c4b3ee4c", "source": "acme-content", "status": "open", "documents": 0, "bytes": 0, "openedAt": "2026-09-23T10:06:45.984Z", "expiresAt": "2026-09-24T10:06:45.984Z" } } } ``` The `Idempotency-Key` is required: a retry with the same key answers the snapshot it already opened, with `200` instead of `201`. A source has at most one open snapshot, so opening another supersedes the first. An open snapshot expires after 24 hours. ## Add documents [Section titled “Add documents”](#add-documents) ```bash curl -X POST "$API/v1/brains/tenant:acme/sources/acme-content/snapshots/01a0cdbb-66e1-75be-a056-c758c4b3ee4c/documents" \ -H "authorization: Bearer $NICIA_KEY" \ -H "content-type: application/json" \ -d @documents.json ``` ```json { "documents": [ { "id": "catalog", "title": "Product catalog", "text": "# Catalog\n\nAcme Widget costs $11.", "metadata": { "drive_file": "1x9Qa" } }, { "id": "gizmo", "text": "# Gizmo\n\nGizmo costs $35." } ] } ``` ```json { "data": { "snapshot": { "id": "01a0cdbb-66e1-75be-a056-c758c4b3ee4c", "source": "acme-content", "status": "open", "documents": 2, "bytes": 58, "openedAt": "2026-09-23T10:06:45.984Z", "expiresAt": "2026-09-24T10:06:45.984Z" }, "documents": [ { "id": "catalog", "status": "changed" }, { "id": "gizmo", "status": "new" } ] } } ``` A snapshot document is a [document](/docs/write/documents/) with a required `id` — your id for it within the source, which is how the next snapshot is compared with this one. Each is acknowledged against the version the source serves now: `new`, `changed`, or `unchanged`. An unchanged document stores nothing new. Documents are Markdown text; converting a PDF or a slide deck is your step, as it is for `POST /documents`. An id may appear once per snapshot: repeating one is `409`, and nothing in that request is stored. ## Add a table [Section titled “Add a table”](#add-a-table) A list you keep in a spreadsheet — leads, accounts, invoices — goes in as a table instead of documents: CSV or TSV text with a header row, and a mapping that says which declared kind the rows are and which column is which field. ```bash curl -X POST "$API/v1/brains/tenant:acme/sources/leads/snapshots/01a0cdbb-66e1-75be-a056-c758c4b3ee4c/tables" \ -H "authorization: Bearer $NICIA_KEY" \ -H "content-type: application/json" \ -d @leads.json ``` ```json { "format": "csv", "text": "Email,Status,Deal size,Notes\nada@acme.com,new,\"1,200\",Met at the conference\nbo@zeta.io,contacted,800,", "mapping": { "kind": "lead", "identityColumn": "Email", "columns": { "Email": "email", "Status": "status", "Deal size": "deal_size_usd" } } } ``` ```json { "data": { "snapshot": { "id": "01a0cdbb-66e1-75be-a056-c758c4b3ee4c", "source": "leads", "status": "open", "documents": 2, "bytes": 122, "openedAt": "2026-09-30T10:06:45.984Z", "expiresAt": "2026-10-01T10:06:45.984Z" }, "rows": [ { "row": 2, "id": "ada-acme-com", "status": "new" }, { "row": 3, "id": "bo-zeta-io", "status": "new" } ] } } ``` Each row becomes one document of the source, and the record it describes: * **Its id** is the identity column’s value made into a slug — `ada@acme.com` is `ada-acme-com` — so the record is addressed by the value you already use. The next upload of the same list finds it by the same id. * **Its text** is every column, mapped or not, as `header: value` lines in header order, so nothing in the file is lost. It is the evidence a receipt pins, but it is not indexed for `/search` passages: a row reaches reads through its record. * **Its row number** comes back in `rows[].row` (the header is row 1, as in a spreadsheet) and is stored as the document’s `row` metadata. A row whose content did not change keeps the version, and the row number, it was first stored with. Sealing reads every mapped cell into its field at once, without a model and without waiting in `extracting`: the snapshot is `planned` when the seal returns. A cell is read as its field’s type — `1,200` is the number 1200, `Contacted` is the enum value `contacted`, `2026-11-02` is a date. An empty cell states nothing. A cell the field cannot hold — a status outside the declared values, `$5k` for a number, `next week` for a date — is kept as a statement aimed at that field, and the rest of the row is read as usual. The whole table is refused with `400`, and nothing in it is stored, when: * the mapping names a kind the schema does not declare, a field the kind does not declare, or a column that is not a header — or maps two columns to a field that holds one value; * a row’s identity is empty, or has no letter or digit to make an id from; * two rows name the same record (`ada@acme.com` and `Ada@Acme.com` are one). Row order cannot say which one is right, so neither is picked. The message names the rows behind each reason. A snapshot holds documents or a table, never both, and every table added to one snapshot carries the same mapping; a large file can be sent in several requests of up to 5,000 rows each. Refreshing works as it does for documents. Seal next week’s export as `complete`, and a lead it no longer holds is `missing`: its record, if only this source supports it, is proposed for deletion. Seal a week’s new rows as `partial`, and nothing is withdrawn. A value a teammate set by hand — a lead marked `contacted` that the export still calls `new` — plans as `contradicts` on a field declared `single` (`disagrees` otherwise) and waits in Review; the upload never overwrites it. ## Seal [Section titled “Seal”](#seal) ```bash curl -X POST "$API/v1/brains/tenant:acme/sources/acme-content/snapshots/01a0cdbb-66e1-75be-a056-c758c4b3ee4c/seal" \ -H "authorization: Bearer $NICIA_KEY" \ -H "content-type: application/json" \ -H "idempotency-key: acme-content-2026-09-23-seal" \ -d '{ "completeness": "complete" }' ``` ```json { "completeness": "complete" } ``` Sealing fixes what the snapshot is compared with — what the source serves at that moment and the Brain’s schema — and answers `202`. `completeness` is the one decision you make here: * **`complete`** says the snapshot is the source’s whole content. A document the source serves that the snapshot leaves out is planned as `missing`, and applying the plan proposes retiring it. * **`partial`** says nothing about documents the snapshot leaves out. They keep being served, exactly as they are. ## Wait for the plan [Section titled “Wait for the plan”](#wait-for-the-plan) ```bash curl "$API/v1/brains/tenant:acme/sources/acme-content/snapshots/01a0cdbb-66e1-75be-a056-c758c4b3ee4c" \ -H "authorization: Bearer $NICIA_KEY" ``` ```json { "data": { "snapshot": { "id": "01a0cdbb-66e1-75be-a056-c758c4b3ee4c", "source": "acme-content", "status": "extracting", "completeness": "complete", "documents": 2, "bytes": 58, "openedAt": "2026-09-23T10:06:45.984Z", "sealedAt": "2026-09-23T10:06:46.025Z", "expiresAt": "2026-09-24T10:06:45.984Z" }, "acks": { "new": 1, "changed": 1, "unchanged": 0 } } } ``` The status read is safe to poll: it returns no content and commits no receipt. A sealed snapshot is `extracting` while a model reads each added or changed document into the fields your Brain’s [schema](/docs/schemas/custom/) declares, then `planned`. A Brain whose kinds declare no fields beyond their identity has nothing to extract, and plans at once — its plan compares documents only. A planned snapshot expires after 7 days if nobody applies it. ## Read the plan [Section titled “Read the plan”](#read-the-plan) ```bash curl "$API/v1/brains/tenant:acme/sources/acme-content/snapshots/01a0cdbb-66e1-75be-a056-c758c4b3ee4c?plan=true" \ -H "authorization: Bearer $NICIA_KEY" ``` ```json { "data": { "snapshot": { "id": "01a0cdbb-66e1-75be-a056-c758c4b3ee4c", "source": "acme-content", "status": "planned", "completeness": "complete", "documents": 2, "bytes": 58, "openedAt": "2026-09-23T10:06:45.984Z", "sealedAt": "2026-09-23T10:06:46.025Z", "plannedAt": "2026-09-23T10:06:46.068Z", "expiresAt": "2026-09-30T10:06:46.068Z" }, "acks": { "new": 1, "changed": 1, "unchanged": 0 }, "plan": { "revision": 1, "counts": { "added": 1, "changed": 1, "unchanged": 0, "missing": 1 }, "observationCounts": { "new": 0, "restated": 0, "changed": 1, "contradicts": 1, "disagrees": 0, "withdrawn": 1, "unknown": 0 }, "findingCounts": { "duplicate": 0, "disagrees": 0, "contradicts": 0 }, "entries": [ { "id": "catalog", "class": "changed", "contentHash": "sha256:5b1f0c…", "baseContentHash": "sha256:9e47d2…", "observations": [ { "class": "changed", "kind": "product", "identity": "Acme Widget", "field": "price_usd", "record": { "id": "acme-widget", "version": 1 }, "value": 11, "quote": "Acme Widget costs $11.", "prior": [10], "supportedBy": [{ "kind": "source", "source": "acme-content", "keyId": "key_7f3a1c" }] } ] }, { "id": "gizmo", "class": "added", "contentHash": "sha256:c2d8a1…", "observations": [ { "class": "contradicts", "kind": "product", "identity": "Gizmo", "field": "price_usd", "record": { "id": "gizmo", "version": 1 }, "value": 35, "quote": "Gizmo costs $35.", "prior": [30], "supportedBy": [{ "kind": "human" }] } ] }, { "id": "launch", "class": "missing", "baseContentHash": "sha256:07ae3f…", "observations": [ { "class": "withdrawn", "kind": "product", "identity": "Bolt", "field": "price_usd", "record": { "id": "bolt", "version": 1 }, "prior": [50], "supportedBy": [{ "kind": "source", "source": "acme-content", "keyId": "key_7f3a1c" }], "effect": "delete_record" } ] } ], "receipt": "rcp_01a0cdbb" } } } ``` `?plan=true` pages the plan, up to 100 documents at a time; walk the rest with `cursor=`. `revision` is what you apply. Each page is a read of what the Brain has accepted, so it commits a receipt of kind `plan` and returns its id. Every **document** has one class: | Class | Means | | ----------- | ------------------------------------------------------- | | `added` | A document id the source has not sent before. | | `changed` | Its content differs from the version the source serves. | | `unchanged` | Same content. Nothing to do. | | `missing` | Served now, absent from a `complete` snapshot. | Every **observation** — one record field a document states — compares the value, and the quote it was read from, with what the Brain has accepted. `supportedBy` says who stands behind the accepted value: sources whose served documents state it, or `human` when none does. | Class | Means | | ------------- | ---------------------------------------------------------------------------------------------------------------- | | `new` | Nothing is accepted at this record and field yet. | | `restated` | Equal to the accepted value. Nothing to write. | | `changed` | Replaces a value only this source supported, which none of its documents states any more. | | `contradicts` | Differs from a value a person or another source supports, on a field declared `single`. | | `disagrees` | The same, on a field with no `single` declaration. The accepted value is kept. | | `withdrawn` | This source no longer states a value it supported. `effect` says what that would do. | | `unknown` | Extraction, identity, or comparison failed. The source’s earlier support is kept, and `reason` says what failed. | A value a person or another source supports is **never overwritten** by a snapshot. That holds whoever sends it: a contradiction still waits in Review at every level below L4, including one sent by a person, whether from the console or with a management key (`nsk_`). Only `changed` writes over an accepted value, and only when this source was its sole support and has stopped stating it in every one of its documents. A document whose extraction failed makes its entries `unknown`, never `withdrawn`. Three rules keep a plan from choosing for you: * **A source that disagrees with itself picks nothing.** When the source’s own documents — this snapshot’s, and those a `partial` snapshot leaves out — state different values for one `single` field, every document is listed as `contradicts` with its own value and quote and `reason: "intra_source"`. No value is written; the entries wait for review until the documents agree again, or a person sets the field. * **A contradiction is listed under every document that states it**, and those documents wait for review together — no fewer, no more. * **A decided contradiction is not asked again.** Once a reviewer has decided a contradiction, a later snapshot restating the same value is `disagrees` with `reason: "already_decided"`, and nothing waits for it. A different value asks again. ## Read the findings [Section titled “Read the findings”](#read-the-findings) ```bash curl "$API/v1/brains/tenant:acme/sources/acme-content/snapshots/01a0cdbb-66e1-75be-a056-c758c4b3ee4c?findings=true" \ -H "authorization: Bearer $NICIA_KEY" ``` `?findings=true` pages what the snapshot’s **own documents** say about each other, grouped by record and field: `duplicate` (documents restate one value), `disagrees` (they state different values), and `contradicts` (they do, on a field declared `single`). Each finding lists every document, value and quote behind it. Like a plan page, a findings page commits a `plan` receipt. The first `complete` snapshot of a source into a Brain that holds nothing from it yet is an **onboarding scan**: every document is `added`, every observation `new` — except where the documents contradict each other on a `single` field, which is `contradicts` and lands nothing — and the findings are the substance: the places a customer’s own material disagrees with itself before any agent reads it. ## Apply [Section titled “Apply”](#apply) ```bash curl -X POST "$API/v1/brains/tenant:acme/sources/acme-content/snapshots/01a0cdbb-66e1-75be-a056-c758c4b3ee4c/apply" \ -H "authorization: Bearer $NICIA_KEY" \ -H "content-type: application/json" \ -d '{ "planRevision": 1 }' ``` ```json { "planRevision": 1 } ``` ```json { "data": { "snapshot": { "id": "01a0cdbb-66e1-75be-a056-c758c4b3ee4c", "source": "acme-content", "status": "awaiting_review", "completeness": "complete", "documents": 2, "bytes": 58, "openedAt": "2026-09-23T10:06:45.984Z", "sealedAt": "2026-09-23T10:06:46.025Z", "plannedAt": "2026-09-23T10:06:46.068Z", "expiresAt": "2026-09-30T10:06:46.068Z", "appliedPlanRevision": 1, "appliedAt": "2026-09-23T10:06:46.099Z", "reviewChangeId": "47b38dd1-48d1-88b1-82c7-1d73ab8fc25a" }, "planRevision": 1, "admitted": { "changeIds": ["brn_9c2a1f:6:1:0", "brn_9c2a1f:6:1:1"] }, "review": { "changeId": "47b38dd1-48d1-88b1-82c7-1d73ab8fc25a", "proposalId": "a5e24e48-3829-4b25-a138-70c9d46422e0" }, "rejected": [], "counts": { "admitted": 1, "review": 2, "rejected": 0 } } } ``` Apply decides the revision you read, document by document, under the Brain’s [write policy](/docs/govern/review/). A document and every field and statement read from it share one outcome, and so does every write to one record, so no passage is served while part of what was read from it waits for review. `counts` are documents: * **Admitted** documents are served at once and their records written. * **Review** documents wait in **one** review change, `review.changeId`. Until a reviewer publishes it, the source keeps serving what it served before. * **Rejected** documents are listed with the policy’s reasons and never staged. At the default rung of the [write policy](/docs/govern/review/) a routine update is admitted, and these wait for a person: a `contradicts` entry, a `withdrawn` entry, a `missing` document, and an `unknown` entry — and a substantive change to a field declared `consequence: review` ([review declarations](/docs/schemas/custom/#review-declarations)). A contradiction is staged as a statement recording what the source said **and** a separate proposed value, so the reviewer can keep the accepted value and record the disagreement, or adopt the new one. A record is published as one write, so when the same snapshot also changes another field of that record the proposed value is its own operation, listed beside the record’s routine change and naming the contested fields. Approving the change, or selecting everything but that operation, keeps the accepted value and the statement still records what the source said; selecting it publishes the record with the new value. A contested value left unselected is declined, not deferred. When the source contradicts itself, each candidate is staged as a statement and no value is proposed: the record is created or kept without the contested field, and the documents stating the candidates wait with them. Extraction reads **declared fields only**. Prose no declared field captures is not interpreted and produces no statement: the document is served and indexed as it is, so nothing it says is lost, and a search or `/context` read finds it there. A statement quoting a document exists only when something cites it, such as a [standing answer](/docs/read/context/) resting on a section of it. The statements a snapshot does write are typed: a candidate a declared field cannot hold, a disagreement with accepted state, or an extra value of a `multiple` field. Once no document the source serves states such a value any more — every document that did is `missing`, or `changed` and no longer says it — apply retracts the statement with those documents, in the same outcome: a retired document’s statements wait in its review. A retracted statement stays readable with `status: "retracted"`; `/context` no longer serves it. Applying the same revision again returns the same body. `409` whose message starts `snapshot_plan_stale` means something the plan read has moved since — what the source serves, a record it compared with, a record it expected to create, or the Brain’s schema. Nothing was applied: seal again (with a new `Idempotency-Key`) and read the new revision. Documents already read are not read again. ## Review what waits [Section titled “Review what waits”](#review-what-waits) The snapshot reports `awaiting_review` with the `reviewChangeId` it waits on. The change is in the review queue, `GET /v1/brains/{handle}/changes?status=staged`, with the `records` it writes and the `documents` it serves or retires (`{ source, id, action }`, `action` being `serve` or `retire`). Inspect it with `GET /v1/brains/{handle}/changes/{changeId}/lifecycle`: every operation names the `document` (`{ source, id }`) it serves or retires, or the `record` (`{ kind, id }`) it writes, and the operations it depends on. A record read from a document depends on the operation that serves that document. Approve and publish the whole change, reject it, or [select part of it](/docs/govern/review/) — for a contradiction, the statement alone keeps the accepted value and records the disagreement, and the statement with the value adopts it, along with the record’s other field changes. Once the change is decided, the snapshot settles `applied` the next time it is read. Applying a later snapshot of the same source recomputes, in an earlier one’s still-pending review, whatever the later snapshot covers: a `complete` one covers every document, a `partial` one the documents it holds. ## Cancel [Section titled “Cancel”](#cancel) ```bash curl -X POST "$API/v1/brains/tenant:acme/sources/acme-content/snapshots/01a0cdbb-66e1-75be-a056-c758c4b3ee4c/cancel" \ -H "authorization: Bearer $NICIA_KEY" ``` Cancelling ends a snapshot that has not been applied. Its documents are never served. ## Receipts after a refresh [Section titled “Receipts after a refresh”](#receipts-after-a-refresh) Every `/context`, `/search` and `/query` receipt pins the exact version of each document it served, named by the source and your id, and the receipt’s snapshot carries an `evidenceSetDigest` that changes whenever the set of served documents does. After a refresh, `/receipts/{receiptId}/now` reports which cited documents changed or were retired, and [the text a receipt pinned](/docs/govern/receipts/#the-text-a-receipt-pinned) is still readable — including a retired document’s — by the key that made the read. ## Limits [Section titled “Limits”](#limits) | Limit | Value | | ------------------------------ | -------------------------- | | Documents per add request | 100 | | Documents per snapshot | 2,000 | | Rows per table request | 5,000 | | Bytes per table request | 10 MB | | Columns per table | 200 | | Rows per snapshot | 20,000 | | Same-apply statement targets | 500 | | Bytes per snapshot | 50 MB | | Plan or findings page | 100 | | Typed values per document | 200 | | Statements per document | 100 | | An open snapshot expires after | 24 hours | | A planned snapshot expires | 7 days after it is planned | A document whose extraction exceeds either per-document cap fails as a whole: its entries are `unknown`, with a `reason` naming the cap, and nothing from it is truncated into the plan. Split a long document, and send tabular data (one record per row) [as a table](#add-a-table) rather than as a document. An apply of any size is one call. One that writes more than 400 records commits them first in batches of at most that many, each its own write, and then serves the documents in a last write; a retry after an interruption resumes from what committed. The only plan refused for size is one holding more than 500 statements about records it is creating in the same apply. ## What a snapshot does not do [Section titled “What a snapshot does not do”](#what-a-snapshot-does-not-do) * **It does not convert files.** Documents are Markdown text, and a table is CSV or TSV text; export a spreadsheet (`.xlsx`) as CSV first. * **It does not read perfectly.** A model reads documents into your declared fields, and it can miss or misread a value. The plan shows the quote behind every value so a person can check it, and a failed read is `unknown`, never a withdrawal. * **It compares values, not units.** A field compared as `contradicts` needs a `single` declaration; a difference the schema cannot describe — a unit, a currency, a qualifier — is at most `disagrees` or `unknown`. * **It does not merge two sources.** A source is one per name in a Brain; any key that may write continues it, and two names are two sources. ## Next [Section titled “Next”](#next) * **[Sources](/docs/write/sources/)** — what a source is and who owns it. * **[Review](/docs/govern/review/)** — deciding what waits. * **[Receipts](/docs/govern/receipts/)** — what a read was served. # Sources > A source is a name you choose in a Brain — one producer's stream of documents, such as one customer's content. Preview — sources and snapshots may change without notice Every source and snapshot operation is preview: it works, but its shape may change or be removed without notice. See [Stable and preview](/docs/api/stability/). A **source** is one producer’s stream of documents: one customer’s content, one repository’s docs folder, one CRM export. You send a source its documents as [snapshots](/docs/write/snapshots/) — the whole current set, or an explicitly partial one — and the Brain compares each snapshot with what the source said before and what the Brain has accepted, before anything is served. Use a source when the content you send has an owner that revises it as a set. Keep using [`POST /documents`](/docs/write/documents/) when you only ever add or rewrite documents one at a time. ## What a source is [Section titled “What a source is”](#what-a-source-is) A source is **a name you choose, one per Brain**. The name is part of the URL; the credential only has to be allowed to write: ```bash curl -X POST "$API/v1/brains/tenant:acme/sources/acme-content/snapshots" \ -H "authorization: Bearer $NICIA_KEY" \ -H "idempotency-key: acme-content-2026-09-23" ``` A name is lowercase letters, digits and hyphens, starts with a letter or digit, and is at most 64 characters. Three things follow from the name being the identity: * **Any key that may write continues the source.** A new key, a replacement for a revoked one, a management key and the console all name `acme-content` and get the same source, the same documents and the same served values. A source never forks into a second copy that answers beside the first. * **The first writer is recorded as its owner.** `owner` names the key that first opened the source. It is attribution, not a gate: it does not change when another key writes, and it does not stop anyone else with the capability. * **One snapshot is open at a time.** Opening a snapshot supersedes the source’s open one, whichever key opened it. A document belongs to exactly one source, addressed by the source and the id you gave it there. The same text sent under two sources is two documents, and retiring a document from one source never touches the other. ## Listing sources [Section titled “Listing sources”](#listing-sources) Any key with `read` can list every source in the Brain, whoever owns it, with each one’s newest snapshot. It answers names, owners and counts — no content: ```bash curl "$API/v1/brains/tenant:acme/sources" \ -H "authorization: Bearer $NICIA_KEY" ``` ```json { "data": { "sources": [ { "name": "acme-content", "owner": { "keyId": "key_7f3a1c" }, "latestSnapshot": { "id": "01a0cdbb-66e1-75be-a056-c758c4b3ee4c", "source": "acme-content", "status": "applied", "completeness": "complete", "documents": 2, "bytes": 58, "openedAt": "2026-09-23T10:06:45.984Z", "sealedAt": "2026-09-23T10:06:46.025Z", "plannedAt": "2026-09-23T10:06:46.068Z", "closedAt": "2026-09-23T10:07:12.310Z", "expiresAt": "2026-09-30T10:06:46.068Z", "appliedPlanRevision": 1, "appliedAt": "2026-09-23T10:06:46.099Z" } } ] } } ``` `GET /v1/brains/{handle}/sources/{name}` reads one: the source with that name. A source exists once a snapshot has been opened for it. ## How reads name a source’s documents [Section titled “How reads name a source’s documents”](#how-reads-name-a-sources-documents) A [receipt](/docs/govern/receipts/) names a document of a source by the source and your id for it: the item carries `document: { "source": "acme-content", "id": "pricing" }` and uses `pricing` as its `source`. `/context` citations and `/search` results do not do that yet. They name a source’s document by the Brain’s own id for it — a `source-document:…` string — which `GET /v1/brains/{handle}/documents/{documentId}` accepts, and which the document listing uses too. ## Sources and `POST /documents` [Section titled “Sources and POST /documents”](#sources-and-post-documents) `POST /documents` is unchanged, and it is not a source. A document written there is served at once, is never compared with anything, and is never retired because a later write left it out. Documents you send through a source and documents you send through `POST /documents` are separate: sending the same id both ways makes two documents. A document written with `POST /documents` therefore cannot be withdrawn: there is no delete for it. If a data room will have drafts that are later superseded or withdrawn, send it through a source, so that a `complete` snapshot that leaves the draft out retires it through review. ## Moving documents between sources [Section titled “Moving documents between sources”](#moving-documents-between-sources) Nothing records that a document “moved”: a document one source stops sending is `withdrawn` by that source’s next `complete` snapshot, and a record only that document supported is proposed for deletion, even when another source now sends the same document. Push the new source’s snapshot first, and let the old source’s `complete` refresh run after it: the record then keeps the new source’s support and the old source’s withdrawal only removes its own. Run in the other order, the record is deleted by the old source’s refresh and created again by the new source’s snapshot, and anything linked to it is left holding a link to a deleted record in the window between. ## Next [Section titled “Next”](#next) * **[Snapshots](/docs/write/snapshots/)** — sending a source its documents. * **[Receipts](/docs/govern/receipts/)** — what a read was served, and the text it pinned. # Statements > Report what a connected source states. Inspect disagreements without promoting either statement to curated truth. A **statement** records what one connected source reported about a subject. The authenticated key supplies source identity; the request cannot impersonate another source. `sourceKey` is optional and identifies a stable row or field inside that source. Writing a statement does not promote it to curated Brain truth. Use `POST /v1/brains/{handle}/statements` to report one. ```bash curl -X POST "$API/v1/brains/tenant:acme/statements" \ -H "authorization: Bearer $NICIA_KEY" \ -H "content-type: application/json" \ -H "idempotency-key: stmt-salesforce-arr" \ -d '{ "subject": "acme", "predicate": "contract_value_usd", "value": 120000, "sourceKey": "salesforce-renewal" }' ``` ```json { "data": { "statementIds": ["stmt_91ab01"], "outcome": { "kind": "admitted", "batchId": "batch_44a1e0", "policyVersionId": "pol_nicia_open", "acceptedSnapshot": "snap_6f1e4d6b", "changeIds": ["brn_9c2a1f:12:0:0"] }, "statements": [ { "id": "stmt_91ab01", "subject": "acme", "predicate": "contract_value_usd", "value": 120000, "validFrom": "2026-08-14T09:20:00.000Z", "sourceKey": "salesforce-renewal", "assertedBy": { "kind": "service", "id": "key_7f3a1c" }, "status": "active" } ] } } ``` The returned `source` is the server-bound producer identity. Credential rotation preserves it. `assertedBy` is projected from the accepted change; callers cannot set either field. A statement without `sourceKey` is valid when the producer has no durable row key: ```bash curl -X POST "$API/v1/brains/tenant:acme/statements" \ -H "authorization: Bearer $NICIA_KEY" \ -H "content-type: application/json" \ -H "idempotency-key: stmt-handbook-pto" \ -d '{ "subject": "acme", "predicate": "pto_starts_on", "value": "day-one", }' ``` A statement can cite the document it was read from, exactly as a [record write](/docs/write/records/#cite-your-sources) does: `evidence` names a document the Brain holds, a quote that must occur in its current version, and your own locator. The citation grounds the statement only, not the subject it is about. Add an `anchor` (`§ ` or `L12-L14`) to rest the statement on a part of the document; the Brain resolves it, and a [standing answer](/docs/read/context/#an-answer-your-own-agent-maintains) that depends on the statement is then monitored. ```json { "subject": "sunfield", "predicate": "migratory_route", "value": "Pacific Flyway", "evidence": [ { "document": { "id": "environmental-report" }, "quote": "within the Pacific Flyway", "locator": { "page": 14 } } ] } ``` ## Sources disagree [Section titled “Sources disagree”](#sources-disagree) A later report for the same source stream supersedes. Another stream or source disagreeing over an overlapping window does not: both statements stay, and the next `/context` names it. You do not call `GET /findings` to find out. That list is the operator inbox. ```bash curl -X POST "$API/v1/brains/tenant:acme/statements" \ -H "authorization: Bearer $NICIA_KEY" \ -H "content-type: application/json" \ -H "idempotency-key: stmt-netsuite-arr" \ -d '{ "subject": "acme", "predicate": "contract_value_usd", "value": 145000, "sourceKey": "netsuite-renewal" }' ``` ```bash curl -X POST "$API/v1/brains/tenant:acme/context" \ -H "authorization: Bearer $NICIA_KEY" \ -H "content-type: application/json" \ -d '{ "prompt": "What is the Acme contract value?", "maxTokens": 2000, "format": "items" }' ``` ```json { "data": { "text": "", "citations": [], "items": [ { "kind": "record", "id": "stmt-salesforce-arr", "fields": { "contract_value_usd": 120000 }, "status": "contested" }, { "kind": "record", "id": "stmt-netsuite-arr", "fields": { "contract_value_usd": 145000 }, "status": "contested" } ], "staged": 0, "warnings": [ { "kind": "contested", "subject": "acme", "predicate": "contract_value_usd", "count": 2, "detail": "Two sources disagree about acme.contract_value_usd." } ], "receipt": "rcp_contested", "truncated": false } } ``` `warnings[]` kind `contested` names the subject and predicate. Per-item `status: "contested"` marks the statements in this answer. There is no `conflicts[]` field on `/context`. ## Resolve [Section titled “Resolve”](#resolve) `GET /findings` is the inbox. `POST /v1/brains/{handle}/findings/{findingId}/decide` keeps one statement or writes a corrected value. The loser is retained, not deleted. The call needs `review` and an `Idempotency-Key`. ```bash curl "$API/v1/brains/tenant:acme/findings?resolved=false" \ -H "authorization: Bearer $NICIA_KEY" ``` ```json { "data": { "findings": [ { "id": "fnd_91ab", "subject": "acme", "predicate": "contract_value_usd", "statements": [ { "id": "stmt_91ab01", "subject": "acme", "predicate": "contract_value_usd", "value": 120000, "validFrom": "2026-08-14T09:20:00.000Z", "sourceKey": "salesforce-renewal", "assertedBy": { "kind": "service", "id": "key_7f3a1c" }, "status": "conflicted" }, { "id": "stmt_91ab02", "subject": "acme", "predicate": "contract_value_usd", "value": 145000, "validFrom": "2026-08-14T09:21:00.000Z", "sourceKey": "netsuite-renewal", "assertedBy": { "kind": "service", "id": "key_7f3a1c" }, "status": "conflicted" } ], "detectedAt": "2026-08-14T09:21:01.000Z", "classification": "contradiction", "coverage": "complete", "reasons": ["declared-single-value-conflict"], "supportFingerprint": "sha256:finding-support", "analysisAsOf": "2026-08-14T09:21:01.000Z" } ], "receipt": "rcp_findings" } } ``` ```bash curl -X POST "$API/v1/brains/tenant:acme/findings/fnd_91ab/decide" \ -H "authorization: Bearer $NICIA_KEY" \ -H "content-type: application/json" \ -H "idempotency-key: resolve-acme-arr" \ -d '{ "keep": "stmt_91ab02", "reviewedReceipt": "rcp_findings", "reviewedFingerprint": "sha256:finding-support" }' ``` ```json { "data": { "outcome": { "kind": "admitted", "batchId": "batch_91ab03", "policyVersionId": "pol_nicia_open", "acceptedSnapshot": "snap_91ab03", "changeIds": ["brn_9c2a1f:15:0:0"] } } } ``` Or send `"value": 145000` instead of `keep` to supersede both with a corrected number. ## Next [Section titled “Next”](#next) * **[Context](/docs/read/context/)** — how a contested fact shows up on the read. * **[Receipts](/docs/govern/receipts/)** — reconstructing a served read, and whether it is stale. * **[Review](/docs/govern/review/)** — Open vs Reviewed, and settling the queue. * **[Records](/docs/write/records/)** — the subjects and sources statements point at.