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

Onboarding: documents to a structured Brain

This is the job: a customer uploads their materials at signup, you pull out their company description, their ideal customer profiles and their products, and you surface it back for them to confirm. Most teams build it as a prompt that returns JSON, then discover the hard parts are all downstream — where the JSON lives, how a human corrects it, what happens on the second upload, and how you prove six months later where a field came from.

This page builds the whole pipeline. Your agent does the conversion and the model call; the Brain holds the source, the typed records, the confirmation queue and the receipts. Every command was run against a live API before it was published.

The shape:

customer's file → your converter → POST /documents (the source, verbatim)
→ your model → POST /batch (typed records, staged)
→ the customer → GET /changes (confirm)
→ your product → POST /query (structured, slug-resolved)

A management key, as in Tutorial 1:

Terminal window
export NICIA_KEY="nsk_…"
export API="https://api.nicia.ai"

This is the step teams skip, and it is the one that decides whether you have a database or a pile of markdown at the end. The kinds and fields you declare are the same shape you will hand your model as its output schema — write them once, here, and derive the prompt from them.

Three kinds is enough for this job.

Terminal window
curl -X PUT "$API/v1/brains/signup:northwind" \
-H "authorization: Bearer $NICIA_KEY" \
-H "content-type: application/json" \
-d '{
"name": "Northwind Robotics",
"purpose": "What Northwind told us at signup.",
"mode": "reviewed",
"schema": {
"extends": "nicia-base",
"kinds": [
{ "name": "company_profile",
"description": "The customer company itself, as it describes itself.",
"fields": [
{ "name": "legal_name", "type": "string" },
{ "name": "one_liner", "type": "string" },
{ "name": "hq_country", "type": "string" },
{ "name": "employee_count", "type": "number" },
{ "name": "source_document", "type": "string" }
] },
{ "name": "icp",
"description": "An ideal customer profile the company sells to.",
"fields": [
{ "name": "segment", "type": "string" },
{ "name": "pain", "type": "string" },
{ "name": "titles", "type": "string_list" },
{ "name": "source_document", "type": "string" }
] },
{ "name": "product",
"description": "A product the company sells.",
"fields": [
{ "name": "product_name", "type": "string" },
{ "name": "category", "type": "string" },
{ "name": "list_price_usd", "type": "number" },
{ "name": "source_document", "type": "string" }
] }
],
"links": [
{ "name": "sells_to", "from": ["product"], "to": ["icp"] }
]
}
}'

Three choices in there are load-bearing.

mode: "reviewed". Most writes from your pipeline and agents now stage instead of landing — that is the “surface it back for confirmation” requirement. Your own edits and additive schema still land immediately; destructive changes always wait. It is a property of the Brain rather than of a credential, so it governs your pipeline, your agents, and anything else that ever writes here.

extends: "nicia-base" keeps person, note and the rest available alongside your three kinds. Omit it only if you want a closed vocabulary, and know that a closed vocabulary with no fieldless kind refuses a write that names no kind at all.

source_document is a declared field, not magic. You will want to know which upload a value came from. On this surface a record you write directly cannot cite a document — see What this does not do yet — so carry the document id as a field you declared yourself. It round-trips, is governed, and is honest about being your bookkeeping rather than the Brain’s. Only exact slug equality is queryable today; predicates on other fields return 400 index_not_ready.

The response tells you the schema was registered under a slug of its own:

{
"data": {
"brain": {
"id": "1DnhbTVVavSqqxo9JQDDS",
"generation": "da26a9ce-9cee-48ce-9cac-21e81370793a",
"handle": "signup:northwind",
"name": "Northwind Robotics",
"purpose": "What Northwind told us at signup.",
"schema": "brain-signup-northwind-7c63abd3",
"mode": "reviewed",
"createdAt": "2026-08-28T07:00:04.945Z",
"updatedAt": "2026-08-28T07:00:04.945Z"
}
}
}

If every customer gets the same three kinds — and for an onboarding flow they should — register the vocabulary once for your organization and name it by slug on each create instead of inlining it. See How schemas work.

The customer uploaded a PDF. The Brain takes text, not bytes: the document write’s body is a JSON text field, and there is no file upload on this surface. Conversion is your step, with whatever you already use — a Markdown converter, a document AI, a parser you own.

Two things to get right while you are down there, because they are much more expensive to add later:

  • Keep the converted text, not just the extracted fields. It is what citations resolve to and what a second pass re-reads.
  • Keep the original filename and the converter you used in metadata. When an extraction turns out to be wrong, the first question is always whether the conversion was.

3. Store the source before anything reads it

Section titled “3. Store the source before anything reads it”
Terminal window
curl -X POST "$API/v1/brains/signup:northwind/documents" \
-H "authorization: Bearer $NICIA_KEY" \
-H "content-type: application/json" \
-d '{
"id": "signup:northwind:overview.pdf",
"title": "Northwind Robotics — company overview",
"text": "# Northwind Robotics — company overview\n\nNorthwind Robotics Ltd is a UK company headquartered in Bristol, employing about 140 people...",
"metadata": { "source": "signup-upload", "original_filename": "overview.pdf", "converted_by": "markitdown" },
"extract": false
}'
{
"data": {
"document": { "id": "signup:northwind:overview.pdf", "status": "queued" }
}
}

id is yours, and the write is an upsert on it, so re-uploading the same file replaces the document rather than duplicating it. Prior versions stay resolvable, which is what keeps a receipt issued last month pointing at the text that was actually shown.

Omit extract and Nicia runs its own extraction over the document. It is a real pipeline and it works, but it is not the one this tutorial wants, and the difference is worth being precise about: Nicia’s built-in extraction writes records of one fixed kind — statement — with a fixed statement vocabulary. It does not read the kinds you declared in step 1 and cannot produce a company_profile or an icp. Declared-kind extraction is not shipped.

So there are two coherent choices and no third:

  • extract: false, and your model produces your kinds — this tutorial, and the right answer when you have designed a vocabulary.
  • Leave extraction on and get statement records alongside whatever you write, useful when you want the Brain’s own reading of prose and have not committed to a shape.

Turning it off is also the difference between one thing to debug and two.

4. Your model turns the document into your records

Section titled “4. Your model turns the document into your records”

Your prompt, your model, your JSON schema — derived from step 1. The output you want is the body of the batch call:

{
"records": [
{
"id": "northwind-robotics",
"kind": "company_profile",
"fields": {
"legal_name": "Northwind Robotics Ltd",
"one_liner": "Warehouse automation that pays for itself in a year.",
"hq_country": "United Kingdom",
"employee_count": 140,
"source_document": "signup:northwind:overview.pdf"
}
},
{
"id": "icp-grocery-distribution",
"kind": "icp",
"fields": {
"segment": "Grocery distribution",
"pain": "Chilled picking is punishing and turnover runs above 60% a year.",
"titles": ["Supply Chain Director", "Site General Manager"],
"source_document": "signup:northwind:overview.pdf"
}
},
{
"id": "gantry-pick",
"kind": "product",
"fields": {
"product_name": "Gantry Pick",
"category": "Overhead picking system",
"list_price_usd": 240000,
"source_document": "signup:northwind:overview.pdf"
},
"links": { "sells_to": ["icp-grocery-distribution"] }
}
]
}

Four rules for the model that are about the Brain rather than about prompting:

  1. id is a lowercase kebab identifier — letters and digits joined by single hyphens. Have the model slugify, or slugify yourself; anything else is a 400. Make the id derivable from the content (gantry-pick) so a second pass over a corrected document updates the record instead of adding a second one.
  2. fields are values and links are edges. Nicia never infers a relationship from a value that looks like one. A link name must be declared, and its target must already exist — which is why gantry-pick and the ICP it points at are in the same batch, where records may reference each other.
  3. Undeclared keys are accepted, stored, and returned — they are outside the declared vocabulary, but they are not discarded. Let the model emit everything it found: nothing is dropped, and a declared field is governed and returned for values already written. Today, only exact slug equality is queryable; every other predicate returns 400 index_not_ready. See when a schema is chosen.
  4. A batch is at most 20 records. Split a large upload across batches; each one lands or stages as a unit.
Terminal window
curl -X POST "$API/v1/brains/signup:northwind/batch" \
-H "authorization: Bearer $NICIA_KEY" \
-H "content-type: application/json" \
-H "idempotency-key: onboard:signup:northwind:v1" \
-d @extracted.json
{
"data": {
"change": {
"id": "vuKs0FldahMRwkz2lRDRC",
"status": "staged",
"reason": "brain_mode_reviewed",
"records": [
{
"id": "northwind-robotics",
"kind": "company_profile",
"set": {
"legal_name": "Northwind Robotics Ltd",
"one_liner": "Warehouse automation that pays for itself in a year.",
"hq_country": "United Kingdom",
"employee_count": 140,
"source_document": "signup:northwind:overview.pdf"
}
}
],
"createdAt": "2026-08-28T07:00:05.534Z"
}
}
}

Branch on change.status, alwaysapplied, staged, or rejected. Here it is staged, with reason: "brain_mode_reviewed" naming why. Nothing is readable yet.

Send the idempotency-key. A batch has no address of its own, so it is the one write where a retry after a timeout is genuinely ambiguous, and Nicia will not invent a key for you — two batches you meant separately are byte-identical to a retry. Replaying the same key returns the stored outcome: the same change id, not a second staged change.

Terminal window
curl -G "$API/v1/brains/signup:northwind/changes" \
-H "authorization: Bearer $NICIA_KEY" \
--data-urlencode "status=staged"
{
"data": {
"changes": [
{
"id": "vuKs0FldahMRwkz2lRDRC",
"status": "staged",
"author": { "kind": "key", "id": "01a0472b-2532-7e55-bd56-b0575231d2fd", "name": "Management key" },
"records": [
{
"id": "icp-grocery-distribution",
"kind": "icp",
"after": {
"segment": "Grocery distribution",
"pain": "Chilled picking is punishing and turnover runs above 60% a year.",
"titles": ["Supply Chain Director", "Site General Manager"],
"source_document": "signup:northwind:overview.pdf"
}
}
],
"reason": "brain_mode_reviewed",
"createdAt": "2026-08-28T07:00:05.553Z"
}
]
}
}

That is your confirmation screen, already assembled: every record the model proposed, the values it proposed, and which credential proposed them. Render records[].after field by field, and let the customer accept it or send you a correction.

One gap to design around: after carries fields, not links. The sells_to edge from step 4 is nowhere in this response — a reviewer confirming from this screen sees the values and not the relationships between them. If relationships matter to your confirmation step, render them from your own extraction output alongside this, rather than from this response.

This is the behaviour that makes a review queue safe to put in front of customers. Ask the Brain a question before anyone has confirmed anything:

Terminal window
curl -X POST "$API/v1/brains/signup:northwind/query" \
-H "authorization: Bearer $NICIA_KEY" \
-H "content-type: application/json" \
-d '{ "kind": "product" }'
{
"data": {
"records": [],
"staged": 1,
"warnings": [
{
"kind": "staged_pending",
"count": 1,
"detail": "1 change is waiting for review, so the Brain may be waiting on a human rather than knowing nothing."
}
],
"receipt": "01a0472b-27e0-7f69-95dc-0aafb25851b7"
}
}

Empty — and it says why. “The Brain knows nothing about this customer” and “the Brain is waiting on a human” have opposite fixes, and this is where your product tells them apart. Wire staged_pending into the onboarding UI: it is the difference between showing “we could not read your documents” and showing “almost done — confirm what we found”.

Terminal window
curl -X POST "$API/v1/brains/signup:northwind/changes/vuKs0FldahMRwkz2lRDRC/approve" \
-H "authorization: Bearer $NICIA_KEY" \
-H "content-type: application/json" \
-d '{}'

The change comes back applied. POST /v1/brains/{handle}/changes/{changeId}/reject is the other half. Both are also the review inbox in app.nicia.ai, which shows the diff and the source side by side — hand a colleague the URL rather than building a queue UI on day one.

Declared fields are preserved and returned. Today /query index-resolves one predicate: exact equality on slug. Address the ICP by the id you wrote:

Terminal window
curl -X POST "$API/v1/brains/signup:northwind/query" \
-H "authorization: Bearer $NICIA_KEY" \
-H "content-type: application/json" \
-d '{ "kind": "icp", "where": { "slug": "icp-grocery-distribution" } }'
{
"data": {
"records": [
{
"id": "icp-grocery-distribution",
"kind": "icp",
"label": "icp-grocery-distribution",
"fields": {
"segment": "Grocery distribution",
"pain": "Chilled picking is punishing and turnover runs above 60% a year.",
"titles": ["Supply Chain Director", "Site General Manager"],
"source_document": "signup:northwind:overview.pdf"
},
"version": 1,
"updatedAt": "2026-08-28T07:00:15.415Z"
}
],
"staged": 0,
"warnings": [],
"receipt": "01a0472b-542d-7002-9b65-ff5e3497d315"
}
}

A predicate on any other field — even one you declared — refuses rather than scanning:

Terminal window
curl -X POST "$API/v1/brains/signup:northwind/query" \
-H "authorization: Bearer $NICIA_KEY" \
-H "content-type: application/json" \
-d '{ "kind": "icp", "where": { "segment": "Grocery distribution" } }'
{
"error": {
"kind": "validation",
"message": "Query predicate field \"segment\" is declared but not indexed (index_not_ready). Only slug equality is index-resolved; this Brain will not scan."
}
}

That is the honest boundary until a property index ships. Derive deterministic slugs from identifiers you already own, or read the record by address after a write. "Which segments does this customer sell to?" is still answered from the returned fields on a slug (or /context) read — just not as a Brain-wide field filter yet.

One scope note before you build a dashboard on it: a query addresses one Brain. With one Brain per customer, “which of our customers sell to grocery distribution” is a loop over Brains that you run and aggregate — there is no cross-Brain query on this surface. What makes that loop tractable is that every Brain is on the same schema, so the fields you read once mean the same thing in all of them. See One Brain per customer.

The same Brain serves prose. Both the original upload and the typed records are in scope, and the citation resolves back to the file the customer gave you:

Terminal window
curl -X POST "$API/v1/brains/signup:northwind/context" \
-H "authorization: Bearer $NICIA_KEY" \
-H "content-type: application/json" \
-d '{ "prompt": "Who does Northwind sell to in grocery, and what do they buy?", "maxTokens": 1200 }'

Records render as their label, kind and values, with links in double brackets:

gantry-pick (product)
product_name: Gantry Pick
category: Overhead picking system
list_price_usd: 240000
source_document: signup:northwind:overview.pdf
sells_to: [[icp-grocery-distribution]]
icp-grocery-distribution (icp)
segment: Grocery distribution
pain: Chilled picking is punishing and turnover runs above 60% a year.
titles: Supply Chain Director, Site General Manager
source_document: signup:northwind:overview.pdf

The citation for the upload names signup:northwind:overview.pdf — the id you chose in step 3 — so it round-trips into GET /v1/brains/{handle}/documents/{documentId} and into your own “view source” link.

The pipeline you just built is already re-runnable, and it is worth knowing why before you add machinery you do not need:

  • The document write is an upsert on your id, so re-converting replaces it.
  • Record writes are PATCH-merge semantics under the batch: fields you send are set, fields you omit are untouched. Re-running your extractor over a corrected document updates the records it still finds and leaves the rest alone.
  • change.fields on the response is what actually moved. An empty array is a no-op, which is the normal case for a re-run and worth counting.
  • On a reviewed Brain, the second pass stages too. That is the intended behaviour — a customer’s confirmation applies to what they confirmed, not to everything the model might say later.

What it does not do is notice that the second upload contradicts the first. See below.

  • No file upload. Documents are text. Conversion is yours.
  • A record you write directly cannot cite a document. sources on a record read is populated only by Nicia’s own extraction; the record write body has no field for it. Carrying the document id as a declared field, as this tutorial does, is the honest workaround — and it is bookkeeping you maintain, not provenance the Brain enforces.
  • Built-in extraction cannot produce your declared kinds. It writes statement records against a fixed vocabulary.
  • The confirmation preview shows fields, not links.
  • Sources that disagree show up on /context. Two uploads that disagree both land; the read names it as warnings[] kind contested plus per-item status. That is disagreement in this answer, not store-wide detection.
  • Extraction progress is not observable on this surface. If you do leave it on: the write acknowledges queued identically whether extraction was requested or not, the document’s status tracks text indexing rather than extraction, and GET /v1/brains/{handle}/documents/{documentId}/records answers an empty list for “still running”, “found nothing”, and “failed” alike. An extraction that fails after it was scheduled reports nowhere. Running the model yourself, as this page does, is also the only way to know it ran.