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

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.

Terminal window
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 }],
});

One block per source, blank-line separated, in the same order as citations:

  • a document that carries frontmatter contributes a name line — the title in its own frontmatter, or the Brain’s name for the page (Evidence <your document id>) when it has none — then every frontmatter key as a key: value line, then a blank line, then its prose. A document with no frontmatter is its prose and nothing else;
  • a record contributes its label and kind (Acme (company), or its id when it has no label) and then its values, one field: value per line;
  • a page with values but no prose — a record written on a Brain’s free-form kind — contributes its label and those values.

Frontmatter is in the block on purpose: it is what the page says about itself, and a Brain never drops an authored field. It is worth knowing before you paste text into a prompt — a document carrying an internal_owner: line puts that line in front of the model.

A document’s prose is the whole document unless it was too large for the budget, in which case it is the passages your prompt matched with […] where text was left out — see Budget, not page size. 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.

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

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

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.

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.

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

If you want the pieces rather than the prose, ask for items and render your own prompt block:

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

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.