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.
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”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.
2026-08-20 — One create, one document
Section titled “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 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,
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 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.
2026-08-19 — Reads are live: /context, /query, and /search
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.
2026-08-19 — Brains and keys are live
Section titled “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: 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
404today. GET /v1/brains/{handle}/searchandGET /v1/brains/{handle}/changesshare 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 its200as 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.