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. That default belongs to this call: a Brain
created in the web app stages agent writes for review instead, so 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, so there is nothing to
filter on until you add some.
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", "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. 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 keys to retry rather than an ambiguous half-applied batch.
const failures: string[] = [];for await (const brain of allBrains()) { try { await nicia.patchSchema(brain.handle, { kinds: [newKind] }); } catch (error) { failures.push(brain.handle); // retry these, nothing else }}Keep concurrency modest — 10 to 20 in flight is plenty — and honour
Retry-After on 429.
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"], 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. See
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.
Deleting
Section titled “Deleting”curl -X DELETE "$API/v1/brains/tenant:acme" \ -H "authorization: Bearer $NICIA_KEY"Deletion is tombstoned: the key 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 issued before deletion still resolve, so an answer you gave a customer last quarter remains explainable after they churn.
Limits
Section titled “Limits”Brain count
Section titled “Brain count”500 per organization will be the default, enforced as 429 with a message
naming the limit — a guardrail against a runaway loop minting unique Brains,
not a ceiling on the product. Today there is no enforced cap; tell us before
you provision at fleet scale regardless, so we can watch for it.
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.