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

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.

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.

Listing is keyset-paged. Cursors are opaque and stable under concurrent writes:

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

prefix will narrow a listing to handles that start with it, for when your handles are structured (tenant:acme, tenant:beta, …):

Terminal window
curl "$API/v1/brains?prefix=tenant:" \
-H "authorization: Bearer $NICIA_KEY"

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, applied directly to Brains still tracking the baseline and staged for review on any Brain that has diverged. That operation isn’t written up here yet.

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.

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"],
idempotencyKey: `app-key:${tenantId}`,
});
return { brain: handle, appSecret: data.secret }; // store the secret; it is shown once
}

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

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.

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.

Terminal window
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 like nicia-core (or no schema at all), never gets a stamp and always reads unrecorded. Lineage is only stamped when schema names an organization-registered slug or is an inline { kinds: [...] } object.
  • independent — stamped, an inline schema with no extends.
  • 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.
  • base-unresolvable — the recorded baseline no longer exists or no longer parses.
Terminal window
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:

Terminal window
curl -X PUT "$API/v1/brains/tenant:acme" \
-H "authorization: Bearer $NICIA_KEY" \
-H "content-type: application/json" \
-d '{ "name": "Acme", "revive": true }'

Receipts issued before deletion still resolve, so an answer you gave a customer last quarter remains explainable after they churn.

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.

Generous, and 429 carries Retry-After. Provisioning on every request is an expected pattern, not an abusive one. See Errors.