Context
/context is the reason to use Nicia. You send the question your agent is about
to answer; you get back text to put in the prompt, the citations behind it, and a
receipt that proves what was shown.
curl -X POST "$API/v1/brains/tenant:acme/context" \ -H "authorization: Bearer $NICIA_KEY" \ -H "content-type: application/json" \ -d '{ "prompt": "What should I know before the renewal call?", "maxTokens": 2000 }'{ "data": { "text": "Acme renewed at $120k for 24 months. Bob Smith (VP Platform) asked for SSO before Q3.\n\nCustomer reports a redirect loop after switching to Okta. Resolved same day.", "citations": [ { "source": "hubspot:deal:412", "slug": "brain-evidence-0dd3e2449851d9ba41924c42bd7a61375a8b78050bfe2ca7192eb918d626236a", "quote": "Acme renewed at $120k for 24 months. Bob Smith (VP Platform) asked for SSO before Q3.", "recordedAt": "2026-03-14T18:02:11Z" }, { "source": "zendesk:ticket:8821", "slug": "brain-evidence-3bb86259c42d0261e0f1cc2d0f7f5f6bb2ee2a9a1cfa8a5b1c5e8a5f0f2a7d31", "quote": "Customer reports a redirect loop after switching to Okta. Resolved same day.", "recordedAt": "2026-08-02T14:20:00Z" } ], "staged": 0, "warnings": [], "receipt": "rcp_019ffefe", "truncated": false }}Drop text into your system prompt. Render citations in your UI. Log
receipt.
source is the id you wrote the document under, and it is the address to
act on: it round-trips into GET /documents/{id} and into your own links.
slug is the Brain’s internal address for the same page — an opaque hash,
derived from the Brain and your document id, for anything written through
this API — and it is there to match a citation against a
/search hit, never to be parsed or displayed. A document
that never arrived through this API (a page authored in the console) carries
slug alone.
const response = await fetch(`${API}/v1/brains/tenant:acme/context`, { method: "POST", headers: { authorization: `Bearer ${NICIA_KEY}`, "content-type": "application/json", }, body: JSON.stringify({ prompt: userQuestion, maxTokens: 2000 }),});const { data } = await response.json();
const answer = await model.complete({ system: `Use only the context below.\n\n${data.text}`, messages: [{ role: "user", content: userQuestion }],});What text is
Section titled “What text is”One block per source, blank-line separated, in the same order as citations:
- a document that carries frontmatter contributes a name line — the
titlein its own frontmatter, or the Brain’s name for the page (Evidence <your document id>) when it has none — then every frontmatter key as akey: valueline, then a blank line, then its prose. A document with no frontmatter is its prose and nothing else; - a record contributes its label and kind (
Acme (company), or its id when it has nolabel) and then its values, onefield: valueper line; - a page with values but no prose — a record written on a Brain’s free-form kind — contributes its label and those values.
Frontmatter is in the block on purpose: it is what the page says about itself,
and a Brain never drops an authored field. It is worth knowing before you paste
text into a prompt — a document carrying an internal_owner: line puts that
line in front of the model.
A document’s prose is the whole document unless it was too large for the budget,
in which case it is the passages your prompt matched with […] where text was
left out — see Budget, not page size. Its name line and
its frontmatter are unchanged either way.
Block n of text is citations[n].quote, character for character — text is
those quotes joined by a blank line and nothing else. That is the property to
lean on: whatever you show a user as a citation is exactly what the model was
shown, so a quote can never be a paraphrase of the answer or a subset of it.
Nothing internal to the Brain’s storage — its opaque ids, its file paths —
appears in either. Read the blocks from citations rather than by splitting
text: prose can contain blank lines of its own.
Budget, not page size
Section titled “Budget, not page size”maxTokens is a budget, not a row count. Nicia orders sources by relevance to
your prompt and fills until the budget is spent, so what a small budget buys
you is the material your question actually named.
Two things decide that order, fused. A record ranks because a declared field holds something your prompt named — which is how a record with no prose at all is retrievable — and because the passage index ranked its text. Neither is a penalty for the other: a typed row your prompt names by customer is found with no prose in the Brain, and a document your prompt matches only in wording is found with no field in common. Sources nothing matched keep a stable order keyed on each source’s id, so the tail never varies.
A third stage may then re-judge the top of that list. Where a Brain is served by a deployment with a reranker, a cross-encoder reads your prompt and each leading candidate together and scores how well that candidate answers it — the one signal the two legs above cannot give you, because neither ever looks at the prompt and the record at the same time. It re-orders the head; everything past it keeps its fused order. The unit it judges is still the source, so a typed record with no prose is reranked on its declared values like anything else.
That means a model can judge relevance, and you can tell whether one did. The
snapshot on every answer carries the retrieval models it was served under —
the embedding model, and the reranker or none — so “which models produced
this text” is recorded rather than assumed. A Brain on a deployment with no
reranker is ordered by the two legs alone and says none, which is what the
whole surface did before reranking existed.
What does not depend on any of that is the receipt. It records the exact sources and versions an answer rested on, so any answer stays reproducible from its receipt whatever changes afterwards — including a change of ranking model. That is the guarantee to build on: not that the ranker is a fixed computation, but that what you were shown is written down.
Truncation follows that order, so what gets left out is the material your prompt matched least — but “least” is not “not at all”:
{ "data": { "truncated": true, "omitted": 12, "warnings": [{ "kind": "truncated", "count": 12 }] }}truncated says the budget ran out; omitted counts the sources that never made
it in. Read a true as “you have not seen everything”, never as “the rest did
not matter”. Raise the budget, or go to /query for
the specific rows you need.
A document too large to serve entire contributes the passages your prompt
matched — the ones the passage index has already read, which is also what earned
it its place in the order — rather than contributing nothing. […] marks every
place the render left text out: before the first passage when it is not the
start of the document, between two passages that were not adjacent, and at the
end. truncated is true for that answer even when omitted is 0, because
you have the parts of that document your question named and not the document.
A record is never excerpted. It is its declared values, and there is nothing to leave out of them, so a record renders whole or does not render — which is also why a Brain of typed records with no prose in it behaves identically whether or not anything has ever been indexed.
A source that fits neither route — too large for the budget, with no matched
passage small enough to stand for it — is skipped rather than treated as a stop
signal, so one oversized source cannot empty an answer it happens to lead. Those
are the sources omitted counts.
The default budget is 2048 tokens and the ceiling is 8192 — both the current
deterministic assembler’s own numbers, and both will rise. Asking for more than
the ceiling is a 400 naming it, never a quiet clamp: a request that was
answered at a budget you did not choose, with truncated: true on it, is
indistinguishable from one whose budget genuinely ran out.
{ "error": { "kind": "validation", "message": "maxTokens: maxTokens must be at most 8192, which is the largest budget the deterministic context assembler renders to." }}No filter, on purpose
Section titled “No filter, on purpose”/context reads the whole Brain. There is no filter parameter: the prompt and
the budget are the selection, and everything the Brain holds is a candidate.
Narrowing lives on /query, where a predicate is
checked against a declared field and an unsupported one comes back as a 400
naming it. Filtering prose retrieval would mean filtering on document metadata
that no read indexes — so rather than accept a filter and quietly ignore it,
/context refuses one:
{ "error": { "kind": "validation", "message": "Unrecognized key: \"filter\"" }}A silently ignored filter is worse than none: you would get a confident, well-formed, whole-Brain answer to a question that asked for a slice, and nothing in the response would tell you.
No subject scoping either
Section titled “No subject scoping either”about is refused for the same reason, and it is the one most worth
explaining. It read as “you already know what this is about, so walk the
relationships from there” — and there is no traversal behind it. Context
assembly projects every source-backed document in the Brain, orders it, and
renders until the budget is spent; there is no subject coordinate anywhere in
that. Accepted and ignored, it would have answered 200 with a whole-Brain
answer to a question that named one account.
{ "error": { "kind": "validation", "message": "Unrecognized key: \"about\"" }}To narrow to one record, read it with /query.
Sources disagree — and /context says so
Section titled “Sources disagree — and /context says so”When two sources make incompatible claims over the same window, Nicia retains
both facts. The read names the disagreement: warnings[] kind contested
plus per-item status. There is no conflicts[] field on /context.
{ "data": { "text": "acme\ncontract_value_usd: 120000", "citations": [ { "slug": "stmt-salesforce-arr", "quote": "acme\ncontract_value_usd: 120000", "recordedAt": "2026-08-14T09:20:00.000Z" } ], "items": [ { "kind": "record", "id": "stmt-salesforce-arr", "fields": { "contract_value_usd": 120000 }, "status": "contested" } ], "staged": 0, "warnings": [ { "kind": "contested", "subject": "acme", "predicate": "contract_value_usd", "count": 2 } ], "receipt": "rcp_contested", "truncated": false }}GET /conflicts is the operator inbox — list, then resolve with
POST /v1/brains/{handle}/conflicts/{conflictId}/resolve and an
Idempotency-Key. The integrator already has the signal from the read.
See Facts.
An empty answer explains itself
Section titled “An empty answer explains itself”{ "data": { "text": "", "citations": [], "staged": 3, "warnings": [ { "kind": "staged_pending", "count": 3, "detail": "3 changes are waiting for review, so the Brain may be waiting on a human rather than knowing nothing." } ], "receipt": "rcp_31af09", "truncated": false }}staged is the number of changes awaiting review — proposals, not the
records inside them, so a batch of twenty records staged as one proposal counts
1. It is the count the review queue itself is measured in, which is where a
non-zero value sends you.
A non-zero staged means the answer may be waiting on a human rather than
missing, and that is the distinction to surface to your users: they are very
different problems. It is usually 0 on an open Brain, whose writes are
admitted as they arrive — but not always, because anything that proposes rather
than writes (Nicia’s own Brain agent stages every change it suggests) puts work
in the queue whatever the mode.
staged can also be null, which means the count could not be taken on this
read. The answer and its receipt are unaffected; what you cannot conclude from a
null is that nothing is waiting. It is nullable for exactly that reason —
reporting a failed count as 0 would say “the Brain knows nothing relevant” on
a Brain that is holding your material for a reviewer.
Why an answer is less than the whole truth
Section titled “Why an answer is less than the whole truth”warnings is present on every read and empty when the answer is complete. There
are five kinds. Four are operational; contested means sources disagree:
| Kind | Means |
|---|---|
processing |
Evidence is accepted but not indexed yet, so a just-written document isn’t in this answer |
access_scoped |
Your credential’s scope excluded matching content — published, not yet emitted by any read |
truncated |
The token budget cut the answer short |
staged_pending |
Matching content exists but is awaiting review |
contested |
Two sources disagree about a subject and predicate in this answer |
count and detail are optional; detail is one sentence written to be safe to
show an end user.
contested is the sources-disagree signal: the answer is not settled. Wire it
when an agent is about to act on a number two sources still fight over.
staged_pending is the other one worth wiring today: it is the difference
between “the Brain knows nothing” and “the Brain is waiting on a human”.
access_scoped is enumerated and emitted by nothing — a Brain key is scoped to
a whole Brain, and nothing filters an answer by credential. It stays published
so a client written today renders it the day a read does.
warnings carries the same five kinds on /search and /query.
processing counts what is outstanding for the read you made, and the same
word means two different things depending on the read.
Nothing is ever withheld from /context or /query for it. A page is in an
accepted-state answer as soon as the Brain accepts it — typed or not, indexed or
not, and whether or not your schema declares a kind that fits it. On /search,
by contrast, a document whose text is not in the passage index yet is genuinely
absent from the results, because ranking passages is all that endpoint does.
What processing tells you on /context is that part of what you were shown
was ordered without its own text being read. Relevance ordering uses the
passage index, so a document still being chunked can only rank on its declared
fields; it is in the answer, but under a tight budget it may not have made the
cut. That is why the sentence in detail says “may not include” rather than “is
not in this answer”. A record with no prose is never counted: it has no
passages, its declared fields are the whole of what it says, and it is fully
rankable the moment it is written.
It is the warning to branch on after a write: an empty /search with a
processing warning means “not yet”, and an empty one with no warnings means
“not here”. Branch on the warning, not on its count: the index check looks at your
most recent writes, which is where an unindexed document is, so a very large
backlog reports what it looked at rather than the total. The same honesty rule
staged: null follows applies here — a count that could not be taken emits no
warning rather than a processing: 0 claiming nothing is outstanding, so
warnings is what the read knows, never a promise that it looked.
Rendering it yourself
Section titled “Rendering it yourself”If you want the pieces rather than the prose, ask for items and render your own prompt block:
-d '{ "prompt": "…", "format": "items" }'{ "data": { "text": "", "citations": [ { "source": "hubspot:deal:412", "slug": "brain-evidence-0dd3e2449851d9ba41924c42bd7a61375a8b78050bfe2ca7192eb918d626236a", "quote": "Acme renewed at $120k for 24 months.", "recordedAt": "2026-03-14T18:02:11Z" } ], "items": [ { "kind": "document", "id": "hubspot:deal:412", "text": "Acme renewed at $120k for 24 months." }, { "kind": "record", "id": "acme", "fields": { "name": "Acme", "arr": 120000 } } ], "staged": 0, "warnings": [], "receipt": "rcp_019ffefe", "truncated": false }}text is empty because you asked for the pieces instead. citations and the
receipt come back either way, so a custom renderer does not cost you
provenance.
Switch on kind. A document is a page nothing has typed and carries text,
rendered the same way /context renders it. A record is a typed row and
carries fields — the same values /query returns, as numbers and booleans
rather than as YAML you would have to pull back out of a string — addressed by
the id PATCH /records/{id} takes, so an item round-trips into a write.
The split is your schema’s, not a guess at one. A piece is a document when it
is filed under the kind your Brain uses for things nobody has typed — your own
free-form kind, the one you declared without fields, or document when you
declared none — and a record otherwise, as long as it carries a value beyond
its prose. So a kind you declare called document (or note) and give fields
to is your kind: its records arrive as record, with your fields.
Cost and latency
Section titled “Cost and latency”One /context call is one round trip and typically 200–500 ms. It does not call
a language model to write anything: nothing is summarised, paraphrased or
generated, so every word you get back is a word your Brain already held. On a
deployment with a reranker, the ranking stage does score the leading candidates
with a small cross-encoder inside that same round trip; on one without, no model
runs at all beyond embedding your prompt.
- Search and queries — when you want rows, not prose.
- Receipts — what that
receiptstring is worth.