Build an agent company on Nicia
You sell an AI product. Every customer of yours has their own accounts, documents, history and vocabulary, and your agent is only as good as what it knows about that customer. This page builds the layer underneath it, start to finish, in one sitting. Every command below was run against a live API before it was published.
By the end you will have two customers, isolated from each other, a document and a typed record in one of them, an agent credential that reaches one Brain and no other, and a cited answer with a receipt.
What you will not have is a replacement for your agent. Nicia is the thing your agent asks before it answers.
Before you start
Section titled “Before you start”You need one credential, and getting the first one is interactive. Sign in at
app.nicia.ai → Settings → API keys → mint one under
Organization keys. That is a management key (nsk_), it is shown once, and
it reaches every Brain in your organization.
export NICIA_KEY="nsk_…"export API="https://api.nicia.ai"Budget for one person in a browser, once. Everything after this is automatable
— including minting more keys, over POST /v1/keys, which itself needs a
signed-in session rather than a key. See
Authentication. Every response is
{ "data": … } on success and { "error": { "kind", "message" } } on failure.
1. A Brain per customer, with no id to store
Section titled “1. A Brain per customer, with no id to store”Provisioning is a pure function of the tenant id you already have.
curl -X PUT "$API/v1/brains/tenant:acme" \ -H "authorization: Bearer $NICIA_KEY" \ -H "content-type: application/json" \ -d '{ "name": "Acme", "purpose": "Everything we know about Acme." }'{ "data": { "brain": { "id": "e72iQTc7tg4e7JTDPCvju", "generation": "d008bb06-3bcc-4f65-bdaa-e8bd78ea188d", "handle": "tenant:acme", "name": "Acme", "purpose": "Everything we know about Acme.", "schema": "nicia-base", "mode": "open", "createdAt": "2026-08-28T06:58:05.027Z", "updatedAt": "2026-08-28T06:58:05.027Z" } }}The first call answers 201. Run the identical command again and it answers
200 with the same id. That is the whole point of addressing a Brain by
your handle: provisioning is one line in your signup path, it is safe to
call on every request, and there is no create-or-get branch, no id-mapping
table, and no reconciliation job.
Two defaults worth knowing before you build on them:
schema: "nicia-base"— a starter vocabulary you did not have to design: the kindsperson,company,deal,conceptandnote, plus the links between them. It declares no fields at all, which matters more than it sounds like; see What you cannot query yet.mode: "open"— most writes land as they arrive; destructive changes still wait for approval. Switch to"reviewed"when you want a human between an agent and the record. Tutorial 2 builds the reviewed path in full.
generation is the Brain’s lifecycle fence. You do not need it for anything on
this page.
2. Put the customer’s context in
Section titled “2. Put the customer’s context in”Documents and typed records are peer on-ramps. Send whichever you have; most companies have both.
curl -X POST "$API/v1/brains/tenant:acme/documents" \ -H "authorization: Bearer $NICIA_KEY" \ -H "content-type: application/json" \ -d '{ "id": "hubspot:deal:412", "title": "Acme renewal call", "text": "Renewal call 2026-03-14. Acme renewed at $120k for 24 months. Bob Smith (Head of Platform) pushed for SSO before Q3.", "metadata": { "source": "hubspot", "type": "call-note" } }'id is yours — use the id from the system the content came from. Sending it
again replaces that document, so your nightly sync is a loop of POSTs with no
“have I sent this already” table behind it.
Nicia also reads the prose and proposes structured records of its own, unless
you send "extract": false. Those arrive as records of kind statement, on a
fixed vocabulary that is not the one you declare — so your /context may carry
blocks beyond the two shown below. Tutorial 2
covers when to leave that on and when to do the extraction yourself.
curl -X PATCH "$API/v1/brains/tenant:acme/records/bob-smith" \ -H "authorization: Bearer $NICIA_KEY" \ -H "content-type: application/json" \ -d '{ "kind": "person", "fields": { "name": "Bob Smith", "role": "Head of Platform" } }'{ "data": { "record": { "id": "bob-smith", "kind": "person", "undeclared": ["name", "role"], "version": 1, "updatedAt": "2026-08-28T06:58:06.902Z" }, "change": { "id": "01a04729-5750-7e17-ad52-3e79c0f4b91c", "status": "applied", "fields": ["name", "role"], "createdAt": "2026-08-28T06:58:06.783Z" } }}Read undeclared carefully — it is the honest half of this response. name
and role were stored, and they will come back on every read, but
nicia-base does not declare them as fields on person, so you cannot filter
on them. Nothing was dropped and nothing needs re-importing — a declared field
governs and returns values that are already there, which is why sending data
before you have finished modelling it costs you nothing. Today, /query
resolves exact slug equality only; predicates on name, role, and every
other non-slug field return 400 index_not_ready. What it does mean is that the
declaring happens on the create call: nicia-core declares name and role on
person, and nicia-base declares no fields at all. See what you cannot query
yet.
3. Give the agent its own key
Section titled “3. Give the agent its own key”Your agent process serves one customer at a time. Give it a credential that can reach one Brain and nothing else — that is the difference between a bug and an incident.
curl -X POST "$API/v1/brains/tenant:acme/keys" \ -H "authorization: Bearer $NICIA_KEY" \ -H "content-type: application/json" \ -d '{ "name": "acme copilot", "capabilities": ["read"] }'{ "data": { "key": { "id": "01a04729-59eb-7604-a5e7-e2cae0993b51", "name": "acme copilot", "capabilities": ["read"], "status": "active", "createdAt": "2026-08-28T06:58:07.464Z" }, "secret": "nbk_f84c32bd…" }}secret is shown once. It is a Brain key (nbk_), bound to tenant:acme, and
it carries read only — so it can ask questions and cannot change anything.
Mint one per customer in the same place you provision the Brain.
export ACME_KEY="nbk_…"4. The call your agent actually makes
Section titled “4. The call your agent actually makes”curl -X POST "$API/v1/brains/tenant:acme/context" \ -H "authorization: Bearer $ACME_KEY" \ -H "content-type: application/json" \ -d '{ "prompt": "What should I know before the renewal call?", "maxTokens": 2000 }'{ "data": { "text": "Renewal call 2026-03-14. Acme renewed at $120k for 24 months. Bob Smith (Head of Platform) pushed for SSO before Q3.\n\nbob-smith (person)\nname: Bob Smith\nrole: Head of Platform", "citations": [ { "source": "hubspot:deal:412", "slug": "brain-evidence-f5ca6d66b4c42e5388ca08a0dade8cf01c76c008fdd49c787267672982ec73e8", "quote": "Renewal call 2026-03-14. Acme renewed at $120k for 24 months. Bob Smith (Head of Platform) pushed for SSO before Q3.", "recordedAt": "2026-08-28T06:58:06.037Z" }, { "slug": "bob-smith", "quote": "bob-smith (person)\nname: Bob Smith\nrole: Head of Platform", "recordedAt": "2026-08-28T06:58:06.902Z" } ], "staged": 0, "warnings": [], "receipt": "01a04729-633a-7086-b63e-b63896318510", "truncated": false }}Paste text into your prompt. Render citations to your user. Log receipt.
Three properties of that response are worth building on:
- Block n of
textiscitations[n].quote, character for character. So whatever you show a user as a citation is exactly what the model was shown. A citation can never be a paraphrase. - No model wrote any of it. Nothing is summarised or generated; every word came out of your Brain. A reranker may score the leading candidates on deployments that have one, but nothing composes prose.
warningsis empty, which is a claim. When it is not empty, the answer is telling you it is less than the whole truth. See step 6.
Wire it up:
const response = await fetch(`${API}/v1/brains/${handle}/context`, { method: "POST", headers: { authorization: `Bearer ${brainKeyFor(customerId)}`, "content-type": "application/json", }, body: JSON.stringify({ prompt: userQuestion, maxTokens: 2000 }),});const { data } = await response.json();
const answer = await model.complete({ system: `Use only the context below.\n\n${data.text}`, messages: [{ role: "user", content: userQuestion }],});// Keep data.receipt beside the answer you store.That deletes your prompt assembler. It is one round trip, and no language model is asked to write anything inside it — see Cost and latency.
5. Prove the isolation before you trust it
Section titled “5. Prove the isolation before you trust it”Provision a second customer and try to cross the line:
curl -X PUT "$API/v1/brains/tenant:globex" \ -H "authorization: Bearer $NICIA_KEY" \ -H "content-type: application/json" \ -d '{ "name": "Globex" }'
curl -X POST "$API/v1/brains/tenant:globex/context" \ -H "authorization: Bearer $ACME_KEY" \ -H "content-type: application/json" \ -d '{ "prompt": "anything", "maxTokens": 500 }'{ "error": { "kind": "not_found", "message": "No Brain is addressable as 'tenant:globex'." }}404, not 403 — a Brain key does not confirm that another customer’s Brain
exists. The read-only key is refused a write in the same way:
curl -X PATCH "$API/v1/brains/tenant:acme/records/bob-smith" \ -H "authorization: Bearer $ACME_KEY" \ -H "content-type: application/json" \ -d '{ "fields": { "role": "VP Platform" } }'{ "error": { "kind": "forbidden", "message": "This Brain key does not hold the 'write' capability." }}And revocation takes effect on the next request, everywhere:
curl -X DELETE "$API/v1/brains/tenant:acme/keys/01a04729-59eb-7604-a5e7-e2cae0993b51" \ -H "authorization: Bearer $NICIA_KEY"The next /context on that secret is a 401. Run these three checks against
your own build; they are the ones an audit will ask about.
6. What a tight budget buys you
Section titled “6. What a tight budget buys you”maxTokens is a budget, not a row count, and under a budget render order is
selection order — so the budget is where you find out whether retrieval is
doing anything. Add three more documents, two of them irrelevant:
curl -X POST "$API/v1/brains/tenant:acme/documents" \ -H "authorization: Bearer $NICIA_KEY" \ -H "content-type: application/json" \ -d '{ "documents": [ { "id": "zendesk:ticket:8821", "title": "SSO login loop after IdP change", "text": "Customer reports a redirect loop after switching to Okta. Resolved same day by clearing the stale assertion cache.", "extract": false }, { "id": "notion:page:rota", "title": "Warehouse rota planning", "text": "Weekly operations note. Shift handover, forklift maintenance scheduling, and agency cover for the night rota.", "extract": false }, { "id": "notion:page:offsite", "title": "Team offsite notes", "text": "Offsite agenda: roadmap review, a session on hiring, and a walk. Lunch is at one. Nothing customer-facing here.", "extract": false } ] }'Then ask three different questions with a budget too small to hold everything:
curl -X POST "$API/v1/brains/tenant:acme/context" \ -H "authorization: Bearer $ACME_KEY" \ -H "content-type: application/json" \ -d '{ "prompt": "Why did Acme have a login loop?", "maxTokens": 300 }'The first citation is zendesk:ticket:8821. Ask
"What should I know before the renewal call?" and it is hubspot:deal:412.
Ask "What is the forklift maintenance situation?" and it is
notion:page:rota. Same Brain, same budget, three different answers — and each
one says what it left out:
{ "data": { "truncated": true, "omitted": 2, "warnings": [ { "kind": "truncated", "count": 2, "detail": "The token budget cut this answer short; 2 sources did not fit." } ] }}Two signals are fused. The passage index ranks a source by its text, and a
declared field ranks one by holding something your prompt named — which is
how a typed record with no prose at all is retrievable. Neither penalises the
other. On this Brain only the first leg is doing work, because nicia-base
declares no fields; Tutorial 2 declares some and
gets both.
Read truncated: true as “you have not seen everything”, never as “the rest did
not matter”, and go to /query when you need specific rows rather than a
budgeted answer.
7. When you want rows, not prose
Section titled “7. When you want rows, not prose”curl -X POST "$API/v1/brains/tenant:acme/query" \ -H "authorization: Bearer $ACME_KEY" \ -H "content-type: application/json" \ -d '{ "kind": "person" }'And ranked passage retrieval, when you want the source rather than an assembled answer:
curl -G "$API/v1/brains/tenant:acme/search" \ -H "authorization: Bearer $ACME_KEY" \ --data-urlencode "q=Okta redirect loop" \ --data-urlencode "limit=5"{ "data": { "results": [ { "source": "zendesk:ticket:8821", "slug": "brain-evidence-be8712c31353463c6f94f8726cc3777c0d688b349eadf410ffc99714353370ee", "title": "Evidence zendesk:ticket:8821", "excerpt": "Customer reports a redirect loop after switching to Okta. Resolved same day by clearing the stale assertion cache.", "rank": 1, "version": 1 } ], "mode": "fulltext", "staged": 0, "warnings": [], "receipt": "01a04729-657c-73f8-b9bc-eb0236443e01" }}mode echoes what the answer was actually ranked by. Asking for hybrid
on a deployment with no embedding provider gives you fulltext and says so,
rather than returning a keyword ranking dressed as a semantic one.
What you cannot query yet
Section titled “What you cannot query yet”This is the first wall most builds hit, so hit it here instead of in production:
curl -X POST "$API/v1/brains/tenant:acme/query" \ -H "authorization: Bearer $ACME_KEY" \ -H "content-type: application/json" \ -d '{ "kind": "person", "where": { "name": "Bob Smith" } }'{ "error": { "kind": "validation", "message": "Query predicate field \"name\" is stored but not indexed (index_not_ready). Equality and has on undeclared keys require the generic property index; this Brain will not scan." }}name was stored. Equality and has on a stored key are legal. This Brain
400s because the generic property index is not ready — index_not_ready —
and the endpoint will not scan to pretend it is. A silently empty page would
be worse. Typed comparators still need a declared field; this is not that
refusal.
Ways forward that work today, without waiting on that index:
- Query by
slugwhen you hold the record’s own id. Exactslugequality is the one index-resolved predicate. - Read declared (or undeclared) values from the record after a slug or
/contexthit — declaration governs and returns shape; it does not unlock non-slug filters yet. - For a fleet whose vocabulary will keep growing, name an organization schema by slug on the create call so every Brain shares the same governed fields. Tutorial 2 and Custom schemas cover declaring that vocabulary.
8. The receipt
Section titled “8. The receipt”Every read commits one, and it is what makes an answer defensible months later:
curl "$API/v1/brains/tenant:acme/receipts/01a04729-633a-7086-b63e-b63896318510" \ -H "authorization: Bearer $ACME_KEY"{ "data": { "receipt": { "id": "01a04729-633a-7086-b63e-b63896318510", "kind": "context", "consumerId": "01a04729-59eb-7604-a5e7-e2cae0993b51", "key": { "id": "01a04729-59eb-7604-a5e7-e2cae0993b51", "name": "acme copilot" }, "connectionId": "01a04729-59eb-7604-a5e7-e2cae0993b51", "snapshot": { "brainId": "e72iQTc7tg4e7JTDPCvju", "generation": "d008bb06-3bcc-4f65-bdaa-e8bd78ea188d", "stateVersion": "sv_01a04729", "schemaVersion": "nicia-base", "policyVersion": "pol_nicia_open" }, "purpose": "ask", "items": [ { "source": "hubspot:deal:412", "version": 1, "status": "active" }, { "source": "bob-smith", "version": 1 } ], "committedAt": "2026-08-28T06:58:09.849Z" } }}It names which credential read, which sources it was served, and at which
version each one stood. GET /v1/brains/{handle}/receipts/{receiptId}/now says
what has changed since. Store the receipt id next to every answer your product
ships.
9. Connecting a desktop agent, and what that costs today
Section titled “9. Connecting a desktop agent, and what that costs today”Everything above is the path for your own agent, which is HTTP, and it is the path this page recommends. If you also want a desktop client — Claude Code, Codex, Cursor — reading the same Brain over MCP, be aware of what that costs right now:
- The supported path is a CLI, and it is not on npm yet. There is no install command that resolves today, so treat Share it with your team as a description of the workflow rather than as something you can run this afternoon.
- The HTTP path exists and works, and is not in the API reference. Minting
the credential a client needs is
POST /v1/brains/{brainId}/connections— addressed by the Brain’s internalid, not its handle, and requiring an explicitx-organization-idheader even though a management key is already bound to one organization. The MCP transport URL you then configure is derived from the brain id and the id that call returns; it is not published in the OpenAPI document, and no operation on the handle-addressed surface hands it to you.
The honest summary is that MCP wiring is discoverable from the product, and not
from this API. If your agent is code you control, use /context and skip it.
What this does not do yet
Section titled “What this does not do yet”Named here so you find them now rather than in week three:
/contextreads the whole Brain. There is no read-time filter; the prompt and the budget are the selection. Narrow with/query.- No temporal reads. You cannot ask what the Brain said last Tuesday. Receipts are the answer to the historical question: they pin exactly which versions a past read was served.
/contextnames contested facts (warnings[]kindcontestedplus per-itemstatus). That is disagreement in this answer, not store-wide detection.GET /conflictsis the operator inbox.- Equality and
hason stored keys are legal. If the generic property index is not ready the endpoint400s withindex_not_ready— it does not scan. Typed comparators still need a declared field. - One organization is the administration boundary. Any member of your organization can administer any Brain in it. If you need administrative separation between your own customers, that is one organization per customer today.
- Onboarding: documents to a structured Brain — the same surface, with your own model doing the extraction and a human confirming it.
- Context — budgets, warnings, and the
itemsformat. - Handing out keys — rotation, expiry, and one key per end user.
- One Brain per customer — running many of these at once.