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)Before you start
Section titled “Before you start”A management key, as in Tutorial 1:
export NICIA_KEY="nsk_…"export API="https://api.nicia.ai"1. Decide the vocabulary first
Section titled “1. Decide the vocabulary first”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.
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.
2. Convert the upload, on your side
Section titled “2. Convert the upload, on your side”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”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.
Why extract: false here
Section titled “Why extract: false here”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
statementrecords 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:
idis a lowercase kebab identifier — letters and digits joined by single hyphens. Have the model slugify, or slugify yourself; anything else is a400. 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.fieldsare values andlinksare 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 whygantry-pickand the ICP it points at are in the same batch, where records may reference each other.- 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
slugequality is queryable; every other predicate returns400 index_not_ready. See when a schema is chosen. - A batch is at most 20 records. Split a large upload across batches; each one lands or stages as a unit.
5. Write it as one batch
Section titled “5. Write it as one batch”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, always — applied, 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.
6. Surface it back for confirmation
Section titled “6. Surface it back for confirmation”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.
7. What a read says while it waits
Section titled “7. What a read says while it waits”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:
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”.
8. Confirm
Section titled “8. Confirm”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.
9. Now it is structured
Section titled “9. Now it is structured”Declared fields are preserved and returned. Today /query index-resolves one
predicate: exact equality on slug. Address the ICP by the id you wrote:
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:
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.
10. And it still answers questions
Section titled “10. And it still answers questions”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:
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 Pickcategory: Overhead picking systemlist_price_usd: 240000source_document: signup:northwind:overview.pdfsells_to: [[icp-grocery-distribution]]
icp-grocery-distribution (icp)segment: Grocery distributionpain: Chilled picking is punishing and turnover runs above 60% a year.titles: Supply Chain Director, Site General Managersource_document: signup:northwind:overview.pdfThe 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 second upload
Section titled “The second upload”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.fieldson 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
reviewedBrain, 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.
What this does not do yet
Section titled “What this does not do yet”- No file upload. Documents are
text. Conversion is yours. - A record you write directly cannot cite a document.
sourceson 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
statementrecords 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 aswarnings[]kindcontestedplus per-itemstatus. 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
queuedidentically whether extraction was requested or not, the document’sstatustracks text indexing rather than extraction, andGET /v1/brains/{handle}/documents/{documentId}/recordsanswers 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.
- Build an agent company on Nicia — the read side, at scale, one Brain per customer.
- Custom schemas — field types, evolution, and bringing a model you already have.
- Review — the confirmation queue in depth.
- Records — merge semantics, concurrency, and links.