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.aiSettings → 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 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.

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"],
"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.

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; 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 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 slug when you hold the record’s own id. Exact slug equality is the one index-resolved predicate.
  • Read declared (or undeclared) values from the record after a slug or /context hit — 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.

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" },
"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 internal id, not its handle, and requiring an explicit x-organization-id header 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.

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 /conflicts is the operator inbox.
  • Equality and has on stored keys are legal. If the generic property index is not ready the endpoint 400s with index_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.