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

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.

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.

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

Terminal window
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 kinds person, company, deal, concept and note, plus the links between them. It declares no fields on those five, which matters more than it sounds like (it also carries the decision, rule and belief kinds); see What you cannot query.
  • 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.

Documents and typed records are peer on-ramps. Send whichever you have; most companies have both.

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

Terminal window
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"],
"token": "brain-record-token-v1:9f2c4b6a1d0e8f735a4c2b9e1d6f0a3c8b5e7d2a4f6c9b1e3d5a7f0c2b4e6d8a",
"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. What it does mean is that you cannot filter on them until they are declared, and declaring happens on the create call: nicia-core declares name and role on person, and nicia-base declares no fields on person. See what you cannot query.

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.

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

Terminal window
export ACME_KEY="nbk_…"
Terminal window
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 text is citations[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.
  • warnings is 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:

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

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

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

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:

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

Terminal window
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 on these kinds; 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.

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

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

This is the first wall most builds hit, so hit it here instead of in production:

Terminal window
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 not declared on kind \"person\". Only declared fields are queryable; declare it in the schema to index it."
}
}

name was stored, and it comes back on every read. It is not queryable because nicia-base does not declare it, and the endpoint will not scan every stored record to pretend otherwise — a silently empty page would be worse. The schema decides what is queryable, never what is storable.

Ways forward:

  • Query by slug when you hold the record’s own id; slug is always queryable.
  • Declare the fields you filter on. On a Brain created with nicia-core, { "where": { "name": "Bob Smith" } } works, and so do ne, in, and ranges on number, date, and datetime fields — see Search and queries.
  • 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.

Every read commits one, and it is what makes an answer defensible months later:

Terminal window
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" },
"snapshot": {
"brainId": "e72iQTc7tg4e7JTDPCvju",
"generation": "d008bb06-3bcc-4f65-bdaa-e8bd78ea188d",
"stateVersion": "sv_01a04729",
"schemaVersion": "nicia-base",
"policyVersion": "pol_nicia_open"
},
"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.

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, the mount is on the same handle you already use everywhere else:

POST https://api.nicia.ai/mcp/brains/{handle}
Authorization: Bearer nbk_...

Mint the key the same way you minted every other Brain key in this guide (POST /v1/brains/{handle}/keys), point your MCP client’s transport URL at that path, and set its bearer token to the secret. An OAuth access token issued for the same Brain and scope also authenticates here — the desktop client and the header carry the same authority either way.

If your agent is code you control, use /context and skip MCP entirely.

Named here so you find them now rather than in week three:

  • /context reads 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.
  • /context names contested facts (warnings[] kind contested plus per-item status). That is disagreement in this answer, not store-wide detection. GET /findings is the operator inbox.
  • Only declared fields are queryable. A predicate on an undeclared key is a 400 naming it — the endpoint does not scan. index_not_ready means a declared field’s index is being rebuilt after its declaration changed.
  • 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.