One Brain per customer
Give every customer their own Brain. Their context stays isolated from every other customer’s, and you never click a button to make one.
const API = "https://api.nicia.ai";
async function brainFor(tenantId: string): Promise<string> { const handle = `tenant:${tenantId}`; const response = await fetch(`${API}/v1/brains/${encodeURIComponent(handle)}`, { method: "PUT", headers: { authorization: `Bearer ${process.env.NICIA_KEY ?? ""}`, "content-type": "application/json", }, body: JSON.stringify({ name: tenantId, schema: "nicia-core" }), }); if (!response.ok) throw new Error(`provision ${tenantId}: ${response.status}`); return handle; // your handle is the address}That is the whole provisioning story. Call it on every request if you like.
Why there is nothing else to do
Section titled “Why there is nothing else to do”Your handle is the address. Every subsequent call is
/v1/brains/tenant:acme/..., so there is no Nicia id to persist, no mapping
table to keep in sync, and no migration if you change how you store tenants.
PUT is create-or-update. First call 201, every identical call after it
200. There is no “does it exist yet” query to race against and no duplicate
Brain to reconcile, so a retry storm is harmless.
A new Brain answers immediately. A Brain this PUT creates starts in open
mode unless the request states otherwise, so the first document you write is
readable the moment it indexes. The web app creates Brains the same way —
open by default — so most writes land and destructive changes still wait;
switch to "reviewed" when you want agent claims to wait for approval. If you
provision both ways, state the mode you mean rather than relying on either.
There is no policy to configure before the Brain is useful, and no schema to
author: naming
nicia-core above gives
every Brain in the fleet the same neutral vocabulary — people, organizations,
topics, events, notes — so one query means the same thing against all of them.
Drop the schema field entirely and each Brain gets
nicia-base instead, which takes
exactly the same writes but declares no typed fields — declaration governs
and returns values later; non-slug /query filters still wait on a property
index either way.
Walking the fleet
Section titled “Walking the fleet”Listing is keyset-paged. Cursors are opaque and stable under concurrent writes:
curl "$API/v1/brains?limit=500" \ -H "authorization: Bearer $NICIA_KEY"{ "data": { "brains": [ { "id": "brain_3a91c7", "generation": "6f1e4d6b-9f0c-4a21-8f3e-1f1a2b3c4d5e", "handle": "tenant:acme", "name": "Acme", "schema": "nicia-base", "mode": "open", "createdAt": "2026-08-14T09:12:04Z" } ], "nextCursor": "eyJrIjoi…" }}async function* allBrains(): AsyncGenerator<Brain> { let cursor: string | undefined; do { const url = new URL(`${API}/v1/brains`); url.searchParams.set("limit", "500"); if (cursor) url.searchParams.set("cursor", cursor); const { data } = await get(url); yield* data.brains; cursor = data.nextCursor; } while (cursor);}Filtering by prefix
Section titled “Filtering by prefix”prefix will narrow a listing to handles that start with it, for when your
handles are structured (tenant:acme, tenant:beta, …):
curl "$API/v1/brains?prefix=tenant:" \ -H "authorization: Bearer $NICIA_KEY"Fan-out
Section titled “Fan-out”There is no fleet-wide write on the Brain API — every Brain-scoped write is addressed to one handle. A change across many Brains is your loop over many Brains, which is deliberate: it keeps every write attributable to one subject and one decision, and it means a partial failure is a list of handles to retry rather than an ambiguous half-applied batch.
The one exception lives on the org plane rather than the Brain API: rolling an
additive schema change out to every Brain built on one of your organization’s
schemas is a single call, POST /v1/schemas/{slug}/rollout, applied directly
to Brains still tracking the baseline, for up to the first 200 Brains on the
slug; a fleet larger than that is not fully reached. A Brain that has diverged, or whose
review declarations the new baseline would lower, is reported failed rather
than written — see your organization’s own
schema.
const failures: string[] = [];for await (const brain of allBrains()) { try { const response = await fetch(`${API}/v1/brains/${encodeURIComponent(brain.handle)}`, { method: "PATCH", headers: { authorization: `Bearer ${process.env.NICIA_KEY ?? ""}`, "content-type": "application/json", }, body: JSON.stringify({ mode: "reviewed" }), }); // `fetch` rejects on network failure only — a 400 or a 429 is a resolved // promise. Check the status or a rejected body fans out silently across // the whole fleet and the run reports zero failures having changed // nothing. if (!response.ok) failures.push(brain.handle); } catch { failures.push(brain.handle); // retry these, nothing else }}Keep concurrency modest — 10 to 20 in flight is plenty — and honour
Retry-After on 429.
Querying across the fleet
Section titled “Querying across the fleet”Reads have one fleet-wide form too, on the same org plane: a typed record query across every Brain built on one of your organization’s schemas. It is the portfolio view over one Brain per customer, deal, or project — “every open diligence question, across every deal”:
curl -X POST "$API/v1/schemas/deal-diligence/query" \ -H "authorization: Bearer $NICIA_KEY" \ -H "content-type: application/json" \ -d '{ "kind": "question", "where": { "status": "open" }, "consumerId": "portfolio-dashboard" }'kind and where take exactly what POST /v1/brains/{handle}/query
takes, but they are checked against the organization schema: a kind or
field it does not declare is one 400, before any Brain is read. Each Brain
is then read the way its own /query would read it — and receipted on that
Brain, under the consumerId you name — so the portfolio view leaves the same
trail as a hundred per-Brain reads.
Every record carries brain, the handle it came from, and every Brain the
page reached is listed in brains with an outcome:
served— read and receipted;receiptis on the entry.claimed— provisioned but never written, so it holds nothing.not_queryable— this Brain does not declare the field the way the schema does, usually because it has not taken a rollout yet.index_not_ready— a field the query reads is being re-indexed after a schema change on this Brain. Retry.failed— the read failed.messagesays why when the reason is yours to act on; an internal failure is logged on our side and says only that the Brain could not be read.
One Brain’s refusal never fails the page. A page reads at most 20 Brains and
up to limit records from each (at most 100); pass nextCursor until it is
absent to walk the whole fleet — there is no ceiling on how many Brains a walk
reaches. A Brain with more matches than limit carries its own nextCursor:
continue that Brain through its own /query with the same filter.
The walk visits provisioned-but-unwritten Brains first, then the rest, each in
a stable order, so walking an unchanging fleet twice gives the same sequence.
A Brain created mid-walk is picked up by the next walk. A Brain written for the
first time mid-walk is never missed: it may appear twice, once as claimed
and once read, but its records appear once.
The query takes the management key, or the console session of a current member
of the organization; a Brain key gets a 401.
Per-customer keys
Section titled “Per-customer keys”Mint the customer’s keys in the same provisioning step so the whole tenant is set up in one function:
async function provision(tenantId: string) { const handle = await brainFor(tenantId); const { data } = await post(`${API}/v1/brains/${handle}/keys`, { name: `${tenantId} app`, capabilities: ["read"] }, { "idempotency-key": `app-key:${tenantId}` }); return { brain: handle, appSecret: data.secret }; // store the secret; it is shown once}An idempotency-key derived from your tenant id means a retried provisioning run
returns the same key rather than minting a second live secret — but the
retry’s response carries secret: null, not the secret. Nicia stores only its
hash, so a secret is only ever shown once, at the call that actually minted
it: keep it from that first successful call. See
Idempotent minting and
Idempotency.
Isolation, honestly
Section titled “Isolation, honestly”Every Brain is logically isolated: its own vocabulary, its own mode, its own keys, and no read path between them. A key scoped to one Brain cannot reach another, and a management key names the Brain it is acting on in every call.
Today all Brains in an organization share one physical store. Per-Brain physical placement is planned, not shipped. If you need physical separation between trust boundaries now, use a separate organization per boundary.
Which Brains track your schema
Section titled “Which Brains track your schema”GET /v1/brains/:handle/schema-lineage answers the fleet question: which
organization schema this Brain forked from, and whether that baseline has
moved since. It needs a management key — a Brain key gets a 401, not a 403, because
lineage exposes org-level schema facts a single-Brain credential isn’t scoped
to, not a permission that credential merely lacks.
curl "$API/v1/brains/tenant:acme/schema-lineage" \ -H "authorization: Bearer $NICIA_KEY"The response is one of five statuses:
unrecorded— no lineage was ever stamped. This is not the same as “no drift”: a Brain provisioned the way the example at the top of this page does it, with a bare built-in slug likenicia-core(or noschemaat all), never gets a stamp and always readsunrecorded. Lineage is only stamped whenschemanames an organization-registered slug or is an inline{ kinds: [...] }object.independent— stamped, an inline schema with noextends.in-sync— the recorded baseline still hashes to what this Brain was built on.base-moved— the organization schema moved since this Brain forked from it, with the current baseline hash and, where computable, a summary of what it’s missing and what it declares differently (changedKinds,changedFieldsByKind— a field’s type, enum values,multiplicity, orconsequence).base-unresolvable— the recorded baseline no longer exists or no longer parses.
Deleting
Section titled “Deleting”curl -X DELETE "$API/v1/brains/tenant:acme" \ -H "authorization: Bearer $NICIA_KEY"Deletion is tombstoned: the handle stays reserved, so a re-running script gets
a 409 conflict rather than a silently empty Brain where the retired one used
to be. Reviving it is explicit:
curl -X PUT "$API/v1/brains/tenant:acme" \ -H "authorization: Bearer $NICIA_KEY" \ -H "content-type: application/json" \ -d '{ "name": "Acme", "revive": true }'Receipts are retained, but they are read through the Brain’s handle, so a deleted
Brain’s receipts answer 404 until it is revived. To keep an answer you gave a
customer last quarter explainable after they churn, take an
export first: it carries receipts.json.
Limits
Section titled “Limits”Brain count
Section titled “Brain count”The cap is per organization and depends on plan — 10 on free, 50 on team, 500
on scale, with 500 as the platform ceiling regardless of plan. Hitting it
returns 429 with a message naming the limit: a guardrail against a runaway
loop minting unique Brains, not a ceiling the product wants you bumping into.
If you’re planning a fleet-scale rollout on the free tier, talk to us first.
Until you verify your email, the cap is 2 — narrower than any plan, and
applied on top of whichever plan resolves. A fresh sign-up wiring this loop up
for the first time therefore gets 429 on its third Brain, which is a
verification step rather than a quota to upgrade past. Verify the address on
the account, and the plan’s own limit applies.
Request rate
Section titled “Request rate”Generous, and 429 carries Retry-After. Provisioning on every request is an
expected pattern, not an abusive one. See Errors.
- Handing out keys — giving a customer access to their own Brain.
- Review — turning on approval per Brain.