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

Records

Not everything you know is prose. A record is a thing you can filter and sort on — optionally typed as a person, an organization, an event.

Terminal window
curl -X PATCH "$API/v1/brains/tenant:acme/records/bob-smith" \
-H "authorization: Bearer $NICIA_KEY" \
-H "content-type: application/json" \
-d '{
"kind": "person",
"fields": { "name": "Bob Smith", "role": "Head of Platform",
"email": "bob@acme.example" },
"links": { "works_at": ["acme"] }
}'
{
"data": {
"record": {
"id": "bob-smith",
"kind": "person",
"undeclared": ["email", "role"],
"version": 3,
"updatedAt": "2026-08-14T09:14:02.118Z"
},
"change": {
"id": "chg_44a1e0",
"status": "applied",
"fields": ["role"],
"records": [
{
"id": "bob-smith",
"kind": "person",
"set": { "role": "Head of Platform" }
}
],
"createdAt": "2026-08-14T09:14:02.118Z"
}
}
}

A write answers what it did, not what the record holds. You get the address, the kind it is filed under, the version to send back as If-Match, which of your fields moved, and — in undeclared — the ones you sent that are not queryable yet. You do not get the record’s values back, including the ones you just sent: a write key can hold write without read, and values leave a Brain only under a receipt. Read them through /context, /query, or the record read.

A body that states no kind, label, fields, or links is a 400. There is nothing to write in it, and it is not a way to read the record back.

A Brain you created thirty seconds ago with no schema step still accepts this — nothing you send is ever rejected for being unrecognized. See Schemas.

kind is optional too. Omit it and the record carries no kind at all — perfectly useful, and typeable later. Attaching a kind never changes the record’s address, so nothing you built against it breaks.

bob-smith in the path is the record’s identity, and it is a string you choose. Use the id you already have — user-4471, a lowercase UUID, crm-account-4471 — and writes become idempotent for free: the address is the identity, so sending the same body twice is one write.

An id is a lowercase kebab identifier: letters and digits, joined by single hyphens. That is the address the record is stored under, so an id outside the rule is a 400 rather than a record you cannot reach later. If your own id has other punctuation — crm:account:4471 — lowercase it and join it with hyphens once, at your edge, and it is still a pure function of the id you already hold.

PATCH merges: the fields you send are set, the fields you omit are left alone. Nicia computes the diff, so your sync job never has to read before it writes.

Terminal window
# Bob got promoted. You know one field. Send one field.
curl -X PATCH "$API/v1/brains/tenant:acme/records/bob-smith" \
-H "authorization: Bearer $NICIA_KEY" \
-H "content-type: application/json" \
-d '{ "fields": { "role": "VP Platform" } }'

The response’s change.fields tells you what actually moved — an empty array means your write was a no-op, which is the normal case for a nightly sync and worth counting.

Use PUT when you are the authority on the whole record and the declared fields you omit should be cleared:

Terminal window
curl -X PUT "$API/v1/brains/tenant:acme/records/bob-smith" \
-H "authorization: Bearer $NICIA_KEY" \
-H "content-type: application/json" \
-d '{ "fields": { "name": "Bob Smith", "role": "VP Platform" } }'

To clear one field with PATCH, send it as null.

PUT clears what your schema declares and you omitted. Frontmatter the schema does not declare — a source_url a markdown import left behind, an imported_by a colleague wrote — survives untouched, because it is stored data no response on this surface shows you, and a write should not delete what it never let you see. Clear one of those the same way PATCH does: send it as null.

Because the address is the identity, PUT and PATCH are naturally idempotent: sending the same body twice is one write, and the second answers change.fields: []. That covers a retry of an UPDATE completely. It cannot cover a create whose answer you never received — nothing is at the address either way — so send an Idempotency-Key on a create if a duplicate would cost you something, on any write verb. Nicia will not invent one for you: the only key it could derive is a hash of your request, and two writes you meant separately are byte-identical to a retry. See Idempotency.

Relationships go in links, separately from fields:

{
"fields": { "name": "Bob Smith", "team": "support/billing" },
"links": { "works_at": ["acme"] }
}

fields are values and links are edges, always. Nicia never infers a relationship from what a value looks like — "support/billing" above is a string, and stays one, because team is a field and not a declared link.

Only names your schema declares as links are accepted in links; anything else is a 400, and so is a target the Brain does not hold yet — an edge is written against both endpoints, so a link to a record that does not exist would be an edge that silently never materializes. Write the target first, or send both in one POST /batch, where records may reference each other.

An edge you send replaces the whole set on that name, so PATCH with "links": { "works_at": [] } removes the relationship, and PATCH with a different target repoints it. A PUT removes every link name it does not send — all of them, with no undeclared exception, because links only ever accepts names your schema declares. You never name the old target — the Brain reads what the record currently holds and writes the difference.

You do not have to know what a record is before writing it. Omit kind, keep your own stable id, and send the fields you already have. The record remains addressable and readable; attach a kind later when you need declared fields and exact queries. Its address does not change.

When several writes must land together — or not at all — send them as one batch:

Terminal window
curl -X POST "$API/v1/brains/tenant:acme/batch" \
-H "authorization: Bearer $NICIA_KEY" \
-H "content-type: application/json" \
-H "idempotency-key: sync:acme:2026-08-14" \
-d '{
"records": [
{ "id": "acme", "kind": "organization",
"fields": { "name": "Acme", "domain": "acme.example" } },
{ "id": "bob-smith", "kind": "person",
"fields": { "name": "Bob Smith" },
"links": { "works_at": ["acme"] } }
]
}'
{
"data": {
"change": {
"id": "chg_5f21b8",
"status": "applied",
"createdAt": "2026-08-14T09:20:11Z"
}
}
}

A batch is validated and applied as one unit: everything lands, everything stages, or everything is rejected. Records in the same batch can link to each other.

A batch carries at most 20 records, and an update counts for more than a create: a record Nicia does not hold yet is one entry in the canonical change, while updating one costs an entry per field that actually changes and per link added or removed, against a ceiling of 100 entries and 2 MiB per batch. A batch that crosses either ceiling is rejected whole, naming the ceiling it crossed, so a partial write is never the thing you have to detect. For a bulk load, send several batches.

The same ceiling applies to a single PATCH or PUT, because it is a ceiling on entries and not on records: updating a hundred-and-twenty fields of one record crosses it on its own. Split that write across requests — it is the one case where sending fewer records is no help at all.

Terminal window
curl "$API/v1/brains/tenant:acme/records/bob-smith" \
-H "authorization: Bearer $NICIA_KEY"
{
"data": {
"record": {
"id": "bob-smith",
"kind": "person",
"fields": { "name": "Bob Smith", "role": "VP Platform" },
"links": { "works_at": ["acme"] },
"sources": ["hubspot:deal:412"],
"version": 4,
"updatedAt": "2026-08-14T09:20:11Z"
},
"staged": 0,
"warnings": [],
"receipt": "rcp_019fff51"
}
}

sources is what makes a record defensible: every field value can be traced to the document or the write that produced it. links comes back the way you sent it — relationships are their own key, never mixed into fields, and never inferred from a value that happens to look like one.

This is exactly what POST /query returns for this record — one record instead of a page, same projection — so it is a read like any other: receipted, with staged and warnings[] saying whether the answer is the whole truth. Keep the receipt and GET /receipts/{id} will tell you which version you were shown. An address the Brain holds nothing at is a 404.

Writes are last-write-wins by default, which is what a sync job wants. When you need optimistic concurrency, use the version you last read:

Terminal window
curl -X PATCH "$API/v1/brains/tenant:acme/records/bob-smith" \
-H "authorization: Bearer $NICIA_KEY" \
-H "if-match: 4" \
-H "content-type: application/json" \
-d '{ "fields": { "role": "CTO" } }'

A stale version is 409 conflict. Re-read, re-apply, resend. PATCH and PUT both honour the header, and a value that is not the integer version is a 400 validation naming the header rather than a precondition that quietly did nothing.

In reviewed mode the response is the same shape with a different status:

{
"data": {
"change": {
"id": "chg_44a1e0",
"status": "staged",
"createdAt": "2026-08-14T09:22:47Z"
}
}
}

Branch on change.status, always: applied, staged, or rejected. A rejection is a 200 — the request was well-formed and the Brain decided against it. See Review.

  • Schemas — the five tiers and what each buys.
  • Context — reading it all back.