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

Changelog

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.

Section titled “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 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”

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 on the same predicate still returns both, which is how you see what happened and retire the stale one.

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 POST /v1/knowledge-graphs, 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 POST /v1/knowledge-graphs. What this one takes instead is schema — a pack slug, or an inline {kinds, links} vocabulary that is fully typed in the 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”

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 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} 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 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”

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”

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.

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”

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”

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

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”

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 GET /v1/knowledge-graphs/{id}/changes. 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, rejectedafter 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 404s. 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”

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”

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”

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 emits — statements have no operations on this surface — 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.

Section titled “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 GET /v1/knowledge-graphs/{id}/search. 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”

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

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: 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} 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 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”

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.

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.

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.

/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 and Search and queries.

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.

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.

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.

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.

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.

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.

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.

2026-08-18 — the surface enters the reference

Section titled “2026-08-18 — the surface enters the reference”

Added — the Brain API is published in the API 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.