Quickstart
From nothing to cited Context in two calls.
const remembered = await fetch(`${base}/brains/ada/records`, {
method: "POST",
headers: {
Authorization: `Bearer ${key}`,
"Content-Type": "application/json",
"Idempotency-Key": "customer-policy-1"
},
body: JSON.stringify({ content: {
kind: "document", title: "Refund policy", text: policy
}})
});
const context = await fetch(`${base}/brains/ada/context`, {
method: "POST",
headers: { Authorization: `Bearer ${key}`, "Content-Type": "application/json" },
body: JSON.stringify({ query: "What is our refund policy?",
include: ["citations", "unknowns", "conflicts"] })
});remembered = requests.post(
f"{base}/brains/ada/records",
headers={**auth, "Idempotency-Key": "customer-policy-1"},
json={"content": {"kind": "document", "title": "Refund policy", "text": policy}},
)
context = requests.post(
f"{base}/brains/ada/context",
headers=auth,
json={"query": "What is our refund policy?",
"include": ["citations", "unknowns", "conflicts"]},
)var request = URLRequest(url: base.appending(path: "brains/ada/context"))
request.httpMethod = "POST"
request.setValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization")
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
request.httpBody = try JSONEncoder().encode([
"query": "What is our refund policy?",
"include": ["citations", "unknowns", "conflicts"]
])
let (data, _) = try await URLSession.shared.data(for: request)curl -s -X POST $BRAIN/brains/ada/records \
-H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \
-H "Idempotency-Key: qs-msg-1" \
-d '{"content":{"kind":"message","text":"my home address is 12 Analytical Way, Turin","speaker":"Ada Lovelace","thread":"sms:+15550100"}}'
curl -s -X POST $BRAIN/brains/ada/context \
-H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \
-d '{"query":"what is my home address?","include":["memories","citations","unknowns"]}'{
"id": "ctx_01KYJE7NVFGKA11WPG28CTVRNA",
"object": "context",
"status": "ready",
"query": "what is my home address?",
"watermark": "wm_000000000004",
"text": "my home address is 12 Analytical Way, Turin",
"citations": [
{
"record_id": "rec_01KYJE7NQ44HR4RJ31VDV0DKKE",
"quote": "my home address is 12 Analytical Way, Turin"
}
],
"conflicts": [],
"unknowns": [],
"budget": {
"requested_tokens": 4000,
"estimated_tokens": 10,
"truncated": false
},
"cost": {
"generative_calls": 0,
"processing_units": 0.0
},
"memories": [
{
"id": "mem_01KYJE7NQ4Y35T92H30MKDPQFD",
"object": "memory",
"statement": "my home address is 12 Analytical Way, Turin",
"subject": "record",
"owner": {
"state": "self",
"entity": "Ada Lovelace"
},
"valid_from": "2026-07-27T18:43:42Z",
"valid_to": null,
"version": 1,
"status": "active",
"citations": [
{
"record_id": "rec_01KYJE7NQ44HR4RJ31VDV0DKKE",
"quote": "my home address is 12 Analytical Way, Turin"
}
]
}
]
}Source: docs/initiatives/memory-api/QUICKSTART.md — this page is rendered from it, not copied. Executed against a real server in CI by scripts/check_quickstart.py: every curl on this page is run and its status compared with the annotation above it.
Date: 2026-07-27 · Ten minutes, three calls, no database. Every response on this page is pasted from a real run. The run is reproduced at the bottom.
The whole thing
POST /v1/brains a Brain, and who it is a memory OF
POST /v1/brains/{id}/records remember anything
POST /v1/brains/{id}/context cited, budgeted, honest contextEverything else here is detail you can skip until you need it.
0. Start a server — 30 seconds
From this repo:
cd ~/Documents/brainComputer/brain-platform
.venv/bin/python -m uvicorn memory_api.app:app --port 8788Anywhere else — Python 3.12+, two packages, no database, no key to sign up for:
pip install fastapi uvicorn
python -m uvicorn memory_api.app:app --port 8788Wait for Uvicorn running on http://127.0.0.1:8788. Interactive docs are at /docs.
What you are running, plainly.
memory_api.appmountsInMemoryStore: the conformance store. It is process-local (restart and it is empty), and its retrieval is a deliberately boring substring scan. It implements the guarantees — citations, ownership, unknowns, watermarks, idempotency, tenant isolation — and none of the intelligence. The engine that does the real retrieval isSpineStore(memory_api/spine.py) over Postgres and the lanes; both satisfy the oneStoreprotocol, so switching is a single line inapp.pyand no route changes. That is why the shapes on this page are real even though its recall is not — and why pointingBRAINat another deployment of the same contract changes nothing below.
1. Your key is your tenant
export BRAIN=http://localhost:8788/v1
export KEY=sk_test_quickstart_adaThe format is sk_<test|live>_<project>_<secret>. The project segment is in the credential, not in a request field, because a request field is something a caller can change. Two keys with different project segments cannot see each other's Brains — proven in step 9. Any well-formed key works against the local server; test and live are separate namespaces, so test data cannot reach live data even by mistake.
Responses below are pretty-printed. Pipe to jq or python3 -m json.tool to get the same.
2. Create a Brain — with self
curl -s -X POST $BRAIN/brains \
-H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \
-d '{"id":"ada","name":"Ada","self":{"type":"person","name":"Ada Lovelace"}}'{
"id": "ada",
"object": "brain",
"created_at": "2026-07-27T18:43:42Z",
"self": {
"type": "person",
"name": "Ada Lovelace"
},
"name": "Ada"
}self is the principal this Brain is a memory of — {type: person | org, name}. It is what makes the word "my" resolvable. Omit it and every possessive question abstains. That is the ownership guarantee, not a bug, and step 5 shows both sides of it.
3. Remember something
Content is a plain string, or a typed object — {kind: message | document | event | fact}:
| kind | required | keeps |
|---|---|---|
message | text | speaker, thread — a thread is what a session IS here; you never create one |
document | text | title |
event | title | starts_at, ends_at, participants, place |
fact | subject, predicate, value | nothing to extract; it is already a fact |
A string is accepted and costs you the structure: flatten a message to prose and the speaker and the thread are gone, and no later stage can get them back.
curl -s -X POST $BRAIN/brains/ada/records \
-H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \
-H "Idempotency-Key: qs-msg-1" \
-d '{"content":{"kind":"message","text":"my home address is 12 Analytical Way, Turin","speaker":"Ada Lovelace","thread":"sms:+15550100"}}'{
"id": "rec_01KYJE7NQ44HR4RJ31VDV0DKKE",
"object": "record",
"status": "ready",
"occurred_at": "2026-07-27T18:43:42Z",
"visibility": "shareable",
"memory_ids": [
"mem_01KYJE7NQ4Y35T92H30MKDPQFD"
],
"created_at": "2026-07-27T18:43:42Z"
}Read status, do not assume it. 202 means durably accepted, not finished. The local store derives synchronously so you get ready immediately; a server that derives in the background returns processing, and the Record becomes ready when its Memories are admitted. Poll GET /brains/{id}/records/{record_id}.
Two more, so there is something to ask about:
curl -s -X POST $BRAIN/brains/ada/records \
-H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \
-d '{"content":{"kind":"fact","subject":"Ada Lovelace","predicate":"prefers","value":"an aisle seat"}}'
curl -s -X POST $BRAIN/brains/ada/records \
-H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \
-d '{"content":{"kind":"event","title":"Turin standup","starts_at":"2026-07-31T09:00:00Z","participants":["Ada Lovelace","Charles Babbage"]}}'Set occurred_at for anything historical. It defaults to now, and "now" is wrong for every import you will ever do — it is what as_of reads later.
4. Get Context — this is the product
curl -s -X POST $BRAIN/brains/ada/context \
-H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \
-d '{"query":"what is my home address?","include":["memories","citations","unknowns"]}'{
"id": "ctx_01KYJE7NVFGKA11WPG28CTVRNA",
"object": "context",
"status": "ready",
"query": "what is my home address?",
"watermark": "wm_000000000004",
"text": "my home address is 12 Analytical Way, Turin",
"citations": [
{
"record_id": "rec_01KYJE7NQ44HR4RJ31VDV0DKKE",
"quote": "my home address is 12 Analytical Way, Turin"
}
],
"conflicts": [],
"unknowns": [],
"budget": {
"requested_tokens": 4000,
"estimated_tokens": 10,
"truncated": false
},
"cost": {
"generative_calls": 0,
"processing_units": 0.0
},
"memories": [
{
"id": "mem_01KYJE7NQ4Y35T92H30MKDPQFD",
"object": "memory",
"statement": "my home address is 12 Analytical Way, Turin",
"subject": "record",
"owner": {
"state": "self",
"entity": "Ada Lovelace"
},
"valid_from": "2026-07-27T18:43:42Z",
"valid_to": null,
"version": 1,
"status": "active",
"citations": [
{
"record_id": "rec_01KYJE7NQ44HR4RJ31VDV0DKKE",
"quote": "my home address is 12 Analytical Way, Turin"
}
]
}
]
}Four things in that response earn their place:
textis the block you paste straight into a prompt. Not chunks for you to assemble.citationsname arecord_idyou can fetch. Every assertion traces to something that entered.costis three numbers, and this page's server is the fixture. The response above comes fromInMemoryStore, which calls no model at all, so every cost field on it is a true0. On the hosted spine the defaultmode=fastreportsmodel_calls_total: 1 · embedding_calls: 1 · generative_calls: 0— exactly one model call, the query embedding, and nothing generated. Read from the ledger, never hardcoded: this was a hardcoded0until 2026-07-29 while a generative query rewrite ran on every request, and then briefly a1under a field named generative for a call that was an embedding. One integer could not say both "it called a model" and "it composed nothing", so there are three. Assert the ones you care about in your tests; they are a contract, not an implementation detail.budget.truncatedis visible. When context is cut to fit, you are told, not quietly served less.
You did not configure any of it. Citations on, conflicts surfaced, unknowns explicit, attribution enforced — the defaults are the product. If a default needs a knob to become correct, the default is wrong.
5. The two honest answers
This is the part worth reading twice. Most memory APIs have exactly one failure mode: they answer anyway.
A miss is not a denial
curl -s -X POST $BRAIN/brains/ada/context \
-H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \
-d '{"query":"what is my passport number?"}'{
"id": "ctx_01KYJE7NWV74QAQWJS0MWXDW13",
"object": "context",
"status": "ready",
"query": "what is my passport number?",
"watermark": "wm_000000000004",
"text": null,
"citations": [],
"conflicts": [],
"unknowns": [
"what is my passport number?"
],
"budget": {
"requested_tokens": 4000,
"estimated_tokens": 0,
"truncated": false
},
"cost": {
"generative_calls": 0,
"processing_units": 0.0
},
"memories": []
}unknowns names the question that could not be answered. Nothing was invented, and nothing was coerced to false, 0, or "" — "we have no evidence" and "it is not so" are different claims and this API never conflates them.
No self, no possessive answer
Create a second Brain, omitting self, and give it the same record:
curl -s -X POST $BRAIN/brains \
-H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \
-d '{"id":"anon"}'
curl -s -X POST $BRAIN/brains/anon/records \
-H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \
-d '{"content":{"kind":"message","text":"my home address is 12 Analytical Way, Turin","speaker":"Ada Lovelace","thread":"sms:+15550100"}}'Now ask the question that worked a moment ago:
curl -s -X POST $BRAIN/brains/anon/context \
-H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \
-d '{"query":"what is my home address?"}'{
"id": "ctx_01KYJE7P16KWMZ8AYD5GRRC0XR",
"object": "context",
"status": "ready",
"query": "what is my home address?",
"watermark": "wm_000000000006",
"text": null,
"memories": [],
"citations": [],
"conflicts": [],
"unknowns": [
"ownership of: what is my home address?"
],
"abstained": {
"reason": "no_attribution"
},
"budget": {
"requested_tokens": 4000,
"estimated_tokens": 0,
"truncated": false
},
"cost": {
"generative_calls": 0,
"processing_units": 0.0
}
}Same evidence, same question, no answer — because nobody said whose Brain this is. The unknown is not the address, it is the ownership of the question, and the API says so.
This is the guarantee: a Memory carries owner: {state: self | other | unresolved}, and unresolved abstains rather than guesses. Somebody else's address in your inbox is not your address, and a system that returns it as yours is confidently wrong — the failure that costs a customer, and the one nobody else's scoreboard has a column for. abstained.reason is one of no_evidence · conflicting_evidence · no_attribution · out_of_policy, so you can branch on which kind of silence you got.
Pass self at create time and the whole class disappears. It is one field.
6. Read your own write
Every write returns a Brain-Watermark:
curl -s -D- -o /dev/null -X POST $BRAIN/brains/ada/records \
-H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \
-d '{"content":"I switched to a standing desk"}' | grep -i -E "^(HTTP|brain-watermark)"HTTP/1.1 202 Accepted
brain-watermark: wm_000000000007Send it back on the next Context and the response is guaranteed to be at or after that boundary. If the server cannot serve it yet you get 202 with status: "processing" — never older evidence labelled current:
curl -s -w "\nHTTP %{http_code}\n" -X POST $BRAIN/brains/ada/context \
-H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \
-H "Brain-Watermark: wm_000000009999" \
-d '{"query":"what is my home address?"}'{"id":"ctx_01KYJE7P305B6PH67CCYHD7H33","object":"context","status":"processing","query":"what is my home address?","watermark":"wm_000000000007","citations":[],"unknowns":["what is my home address?"],"budget":{"estimated_tokens":0,"truncated":false}}
HTTP 202Retry until status is ready. Omit the header and you get the freshest thing available, which is the right default for reads that are not chasing a write you just made.
7. Retry without fear
Replay step 3 verbatim — same Idempotency-Key, same body:
{
"id": "rec_01KYJE7NQ44HR4RJ31VDV0DKKE",
"object": "record",
"status": "ready",
"occurred_at": "2026-07-27T18:43:42Z",
"visibility": "shareable",
"memory_ids": [
"mem_01KYJE7NQ4Y35T92H30MKDPQFD"
],
"created_at": "2026-07-27T18:43:42Z"
}The original Record, same id, no second Memory. Send a key on every write from a retrying client; on the spine it is a UNIQUE constraint, so the database is the lock and there is no window where two retries both win.
8. See what the system inferred
curl -s "$BRAIN/brains/ada/memories?limit=1" -H "Authorization: Bearer $KEY"{
"data": [
{
"id": "mem_01KYJE7NQ4Y35T92H30MKDPQFD",
"object": "memory",
"statement": "my home address is 12 Analytical Way, Turin",
"subject": "record",
"owner": {
"state": "self",
"entity": "Ada Lovelace"
},
"valid_from": "2026-07-27T18:43:42Z",
"valid_to": null,
"version": 1,
"status": "active",
"citations": [
{
"record_id": "rec_01KYJE7NQ44HR4RJ31VDV0DKKE",
"quote": "my home address is 12 Analytical Way, Turin"
}
]
}
],
"has_more": true,
"next_cursor": "mem_01KYJE7NRHXP1D8HR3GE856Y39"
}citations is never empty: a Memory that cannot name its evidence is not written. valid_to: null means current — supersession writes a new version and keeps the old one, so history is never overwritten. Page with ?cursor=<next_cursor>.
Four layers, and no layer silently acquires the authority of the layer after it:
Record = what entered Memory = what the system inferred
Object = what YOUR APP accepted (POST /objects — the only place authority moves; a model may never call it)
Context = what is eligible for this task9. When something goes wrong
One shape, every failure, no exceptions — {type, code, message, request_id, docs_url}:
$ curl -s $BRAIN/brains/ada # no key
{"type":"authentication","code":"invalid_api_key","message":"expected `sk_<test|live>_<project>_<secret>`",...} 401
$ ... -d '{"content":{"kind":"note","text":"hello"}}' # unknown kind
{"type":"invalid_request","code":"invalid_request_body","message":"content: Input tag 'note' found using 'kind' does not match any of the expected tags: 'message', 'document', 'event', 'fact'",...} 400
$ ... -d '{"content":"hello","visibility":"pubic"}' # typo in an enum
{"type":"invalid_request","code":"invalid_request_body","message":"visibility: Input should be 'local_only', 'redacted_summary' or 'shareable'",...} 400
$ curl -s $BRAIN/brains/ada -H "Authorization: Bearer sk_test_someoneelse_zzz"
{"type":"not_found","code":"brain_not_found","message":"no Brain 'ada'",...} 404
$ curl -s -X POST $BRAIN/brains -d '{"id":"ada"}' ... # already exists
{"type":"conflict","code":"brain_exists","message":"Brain 'ada' exists",...} 409type is the branch you write code against — invalid_request · authentication · permission · not_found · conflict · rate_limit · policy · internal. request_id is what a support ticket quotes. A typo'd query parameter is refused rather than ignored (?limitt=5 → 400 unknown_query_parameter), because a bound you asked for and did not get is worse than an error.
Note the fourth one: another project's key gets 404, not 403. A different tenant is not told that ada exists.
The same thing in TypeScript
No dependencies — fetch is built in. Run it with node quickstart.ts: Node 24 strips the types itself, no flags and no build step (that is what was run below). On older Node, drop the two type annotations and save it as .mjs.
const BASE = "http://localhost:8788/v1";
const KEY = "sk_test_quickstart_ada";
async function brain(path: string, init: RequestInit = {}): Promise<any> {
const res = await fetch(BASE + path, {
...init,
headers: { Authorization: `Bearer ${KEY}`, "Content-Type": "application/json", ...init.headers },
});
const body = await res.json();
// Every failure is the same shape, so there is one error branch to write, not one per endpoint.
if (!res.ok) throw new Error(`${body.type}/${body.code}: ${body.message}`);
return body;
}
const post = (path: string, payload: unknown) =>
brain(path, { method: "POST", body: JSON.stringify(payload) });
// 1. A Brain, WITH self. Omit `self` and every possessive question below abstains.
await post("/brains", { id: "ada-ts", self: { type: "person", name: "Ada Lovelace" } });
// 2. Remember. A typed record keeps the speaker and the thread; a string would lose both.
await post("/brains/ada-ts/records", {
content: {
kind: "message",
text: "my home address is 12 Analytical Way, Turin",
speaker: "Ada Lovelace",
thread: "sms:+15550100",
},
});
// 3. Context — cited, budgeted, and honest when it has nothing.
const ctx = await post("/brains/ada-ts/context", {
query: "what is my home address?",
include: ["citations", "memories"],
});
console.log("text: ", ctx.text);
console.log("abstained: ", ctx.abstained ?? null);
console.log("unknowns: ", ctx.unknowns);
for (const c of ctx.citations) console.log("cited: ", c.record_id, "|", c.quote);
console.log("generative calls:", ctx.cost.generative_calls);$ node quickstart.ts
text: my home address is 12 Analytical Way, Turin
abstained: null
unknowns: []
cited: rec_01KYJE930AKFMCK6BEDPB7JQXX | my home address is 12 Analytical Way, Turin
generative calls: 0The same thing in Python
Standard library only, so there is nothing to install. httpx or requests work the same way.
import json
import urllib.error
import urllib.request
BASE, KEY = "http://localhost:8788/v1", "sk_test_quickstart_ada"
def brain(path, payload=None, method="GET"):
req = urllib.request.Request(
BASE + path, method=method,
data=json.dumps(payload).encode() if payload is not None else None,
headers={"Authorization": f"Bearer {KEY}", "Content-Type": "application/json"})
try:
with urllib.request.urlopen(req) as res:
return json.load(res)
except urllib.error.HTTPError as exc:
# Every failure is the same shape, so there is one error branch to write, not one per call.
err = json.load(exc)
raise SystemExit(f"{err['type']}/{err['code']}: {err['message']}")
def post(path, payload):
return brain(path, payload, "POST")
# 1. A Brain, WITH self. Omit `self` and every possessive question below abstains.
post("/brains", {"id": "ada-py", "self": {"type": "person", "name": "Ada Lovelace"}})
# 2. Remember. A typed record keeps the speaker and the thread; a string would lose both.
post("/brains/ada-py/records", {"content": {
"kind": "message",
"text": "my home address is 12 Analytical Way, Turin",
"speaker": "Ada Lovelace",
"thread": "sms:+15550100",
}})
# 3. Context — cited, budgeted, and honest when it has nothing.
ctx = post("/brains/ada-py/context", {
"query": "what is my home address?",
"include": ["citations", "memories"],
})
print("text: ", ctx["text"])
print("abstained: ", ctx.get("abstained"))
print("unknowns: ", ctx["unknowns"])
for c in ctx["citations"]:
print("cited: ", c["record_id"], "|", c["quote"])
print("generative calls:", ctx["cost"]["generative_calls"])$ python quickstart.py
text: my home address is 12 Analytical Way, Turin
abstained: None
unknowns: []
cited: rec_01KYJE932SGWXWC0ZQFDNZ4FCW | my home address is 12 Analytical Way, Turin
generative calls: 0What the local server will not do for you
So you do not mistake the reference for the engine:
- Retrieval is a substring scan. Your question must share a word with the evidence.
"where do I live?"misses the address record on this server and returnsunknowns— verified. The lanes that make that question work (state, vector, person, graph, fused) are the engine's, not the contract's. - One Memory per Record,
subject: "record". No extraction, no entity resolution, no supersession, no conflicts. include: ["explain"]now returns a real lane breakdown — the fixture emits it honestly (it was empty until the trace work landed, so an older copy of this doc said otherwise). The contract declares it; this store does not implement it.- Nothing is durable. Restart the process and you have an empty server.
mode: "thorough"is accepted and returns exactly whatfastreturns. ~~and then reportscost.generative_calls: 1— a call it did not make.~~ (Fixed 2026-07-29: the fixture used to report a placeholder1forthoroughwhile making no call at all. It now reportsmodel_calls_total: 0 · embedding_calls: 0 · generative_calls: 0on both modes, which is the truth about a store that calls no model — verified by running it.) Do not calibrate cost against this server either way: on the spine the two modes genuinely differ,fastspending one embedding andthoroughadding a generative query rewrite.
What it does do is the whole reason it exists: it makes the guarantees testable without a database, and it is what the conformance fuzzer runs against.
Next
openapi.yaml— the contract, and the source of truth. Not the server's generated schema.INTEGRATION_GUIDE.md— three real applications on the same five resources, with no per-customer branch anywhere.BACKLOG_10X.md— what is being built next, and the measurement behind each.- The seven read knobs you have not touched yet:
purpose · as_of · budget · include · schema · filter · mode. You reached a cited answer without any of them, which is the point.
Verified
Every command and response above was executed on 2026-07-27 against .venv/bin/python -m uvicorn memory_api.app:app --port 8788 (InMemoryStore, Python 3.13.13). Nothing here is illustrative.
- Mechanically re-checked. Every command on this page was re-run in order against a freshly started server and each of the seven pasted JSON responses was diffed against the live one. All seven match once generated ids and timestamps are normalised — that is the only thing that differs between runs.
- The whole curl sequence takes about a second of machine time. The ten minutes is you reading.
- The bare-install path works and has a floor.
python3.13 -m venv+pip install fastapi uvicorn(fastapi 0.140.7 / pydantic 2.13.4 / uvicorn 0.51.0, no repo dependencies) served the same cited Context. The same venv on Python 3.9 fails to start —Unable to evaluate type annotation 'str | None'. The package declaresrequires-python >=3.12. - The TypeScript file ran on Node v24.18.0 via
node quickstart.ts, no flags, no packages; the type-stripped.mjsvariant ran too. The Python file ran on 3.13.13, standard library only. - Every claim in prose was checked the same way, including the ones that make this page look worse: the
"where do I live?"miss,include: ["explain"]returning nothing, andthorough's fabricatedgenerative_calls: 1. - Gates on this code:
pytest memory_api/tests/14 passed / 3 skipped ·scripts/check_api_simplicity.pyGREEN · schemathesis against the checked-in contract, 3 known findings — identical before and after the fixes this page's first run produced.
# Quickstart — from nothing to a cited Context
**Date:** 2026-07-27 · Ten minutes, three calls, no database.
Every response on this page is **pasted from a real run**. The run is reproduced at the bottom.
---
## The whole thing
```
POST /v1/brains a Brain, and who it is a memory OF
POST /v1/brains/{id}/records remember anything
POST /v1/brains/{id}/context cited, budgeted, honest context
```
Everything else here is detail you can skip until you need it.
---
## 0. Start a server — 30 seconds
From this repo:
```bash
cd your checkout/brain-platform
.venv/bin/python -m uvicorn memory_api.app:app --port 8788
```
Anywhere else — Python 3.12+, two packages, no database, no key to sign up for:
```bash
pip install fastapi uvicorn
python -m uvicorn memory_api.app:app --port 8788
```
Wait for `Uvicorn running on http://127.0.0.1:8788`. Interactive docs are at `/docs`.
> **What you are running, plainly.** `memory_api.app` mounts `InMemoryStore`: the conformance
> store. It is process-local (restart and it is empty), and its retrieval is a deliberately boring
> substring scan. It implements the *guarantees* — citations, ownership, unknowns, watermarks,
> idempotency, tenant isolation — and none of the intelligence. The engine that does the real
> retrieval is `SpineStore` (`memory_api/spine.py`) over Postgres and the lanes; both satisfy the
> one `Store` protocol, so switching is a single line in `app.py` and no route changes. That is why
> the shapes on this page are real even though its recall is not — and why pointing `BRAIN` at
> another deployment of the same contract changes nothing below.
---
## 1. Your key is your tenant
```bash
export BRAIN=http://localhost:8788/v1
export KEY=sk_test_quickstart_ada
```
The format is `sk_<test|live>_<project>_<secret>`. The project segment is in the **credential**, not
in a request field, because a request field is something a caller can change. Two keys with
different project segments cannot see each other's Brains — proven in step 9. Any
well-formed key works against the local server; `test` and `live` are separate namespaces, so test
data cannot reach live data even by mistake.
Responses below are pretty-printed. Pipe to `jq` or `python3 -m json.tool` to get the same.
---
## 2. Create a Brain — with `self`
```bash
curl -s -X POST $BRAIN/brains \
-H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \
-d '{"id":"ada","name":"Ada","self":{"type":"person","name":"Ada Lovelace"}}'
```
```json
{
"id": "ada",
"object": "brain",
"created_at": "2026-07-27T18:43:42Z",
"self": {
"type": "person",
"name": "Ada Lovelace"
},
"name": "Ada"
}
```
`self` is the principal this Brain is a memory **of** — `{type: person | org, name}`. It is what
makes the word "my" resolvable. **Omit it and every possessive question abstains.** That is the
ownership guarantee, not a bug, and step 5 shows both sides of it.
---
## 3. Remember something
Content is a plain string, or a typed object — `{kind: message | document | event | fact}`:
| kind | required | keeps |
|---|---|---|
| `message` | `text` | `speaker`, `thread` — a thread is what a session IS here; you never create one |
| `document` | `text` | `title` |
| `event` | `title` | `starts_at`, `ends_at`, `participants`, `place` |
| `fact` | `subject`, `predicate`, `value` | nothing to extract; it is already a fact |
A string is accepted and costs you the structure: flatten a message to prose and the speaker and the
thread are gone, and no later stage can get them back.
```bash
curl -s -X POST $BRAIN/brains/ada/records \
-H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \
-H "Idempotency-Key: qs-msg-1" \
-d '{"content":{"kind":"message","text":"my home address is 12 Analytical Way, Turin","speaker":"Ada Lovelace","thread":"sms:+15550100"}}'
```
```json
{
"id": "rec_01KYJE7NQ44HR4RJ31VDV0DKKE",
"object": "record",
"status": "ready",
"occurred_at": "2026-07-27T18:43:42Z",
"visibility": "shareable",
"memory_ids": [
"mem_01KYJE7NQ4Y35T92H30MKDPQFD"
],
"created_at": "2026-07-27T18:43:42Z"
}
```
**Read `status`, do not assume it.** `202` means durably accepted, not finished. The local store
derives synchronously so you get `ready` immediately; a server that derives in the background
returns `processing`, and the Record becomes `ready` when its Memories are admitted. Poll
`GET /brains/{id}/records/{record_id}`.
Two more, so there is something to ask about:
```bash
curl -s -X POST $BRAIN/brains/ada/records \
-H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \
-d '{"content":{"kind":"fact","subject":"Ada Lovelace","predicate":"prefers","value":"an aisle seat"}}'
curl -s -X POST $BRAIN/brains/ada/records \
-H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \
-d '{"content":{"kind":"event","title":"Turin standup","starts_at":"2026-07-31T09:00:00Z","participants":["Ada Lovelace","Charles Babbage"]}}'
```
Set `occurred_at` for anything historical. It defaults to now, and "now" is wrong for every import
you will ever do — it is what `as_of` reads later.
---
## 4. Get Context — this is the product
```bash
curl -s -X POST $BRAIN/brains/ada/context \
-H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \
-d '{"query":"what is my home address?","include":["memories","citations","unknowns"]}'
```
```json
{
"id": "ctx_01KYJE7NVFGKA11WPG28CTVRNA",
"object": "context",
"status": "ready",
"query": "what is my home address?",
"watermark": "wm_000000000004",
"text": "my home address is 12 Analytical Way, Turin",
"citations": [
{
"record_id": "rec_01KYJE7NQ44HR4RJ31VDV0DKKE",
"quote": "my home address is 12 Analytical Way, Turin"
}
],
"conflicts": [],
"unknowns": [],
"budget": {
"requested_tokens": 4000,
"estimated_tokens": 10,
"truncated": false
},
"cost": {
"generative_calls": 0,
"processing_units": 0.0
},
"memories": [
{
"id": "mem_01KYJE7NQ4Y35T92H30MKDPQFD",
"object": "memory",
"statement": "my home address is 12 Analytical Way, Turin",
"subject": "record",
"owner": {
"state": "self",
"entity": "Ada Lovelace"
},
"valid_from": "2026-07-27T18:43:42Z",
"valid_to": null,
"version": 1,
"status": "active",
"citations": [
{
"record_id": "rec_01KYJE7NQ44HR4RJ31VDV0DKKE",
"quote": "my home address is 12 Analytical Way, Turin"
}
]
}
]
}
```
Four things in that response earn their place:
- **`text`** is the block you paste straight into a prompt. Not chunks for you to assemble.
- **`citations`** name a `record_id` you can fetch. Every assertion traces to something that entered.
- **`cost` is three numbers, and this page's server is the fixture.** The response above comes from
`InMemoryStore`, which calls no model at all, so every cost field on it is a true `0`. On the
hosted spine the default `mode=fast` reports **`model_calls_total: 1 · embedding_calls: 1 ·
generative_calls: 0`** — exactly one model call, the query embedding, and nothing generated.
Read from the ledger, never hardcoded: this was a hardcoded `0` until 2026-07-29 while a
generative query rewrite ran on every request, and then briefly a `1` under a field named
*generative* for a call that was an embedding. One integer could not say both "it called a model"
and "it composed nothing", so there are three. Assert the ones you care about in your tests; they
are a contract, not an implementation detail.
- **`budget.truncated`** is visible. When context is cut to fit, you are told, not quietly served less.
You did not configure any of it. Citations on, conflicts surfaced, unknowns explicit, attribution
enforced — the defaults are the product. If a default needs a knob to become correct, the default
is wrong.
---
## 5. The two honest answers
This is the part worth reading twice. Most memory APIs have exactly one failure mode: they answer
anyway.
### A miss is not a denial
```bash
curl -s -X POST $BRAIN/brains/ada/context \
-H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \
-d '{"query":"what is my passport number?"}'
```
```json
{
"id": "ctx_01KYJE7NWV74QAQWJS0MWXDW13",
"object": "context",
"status": "ready",
"query": "what is my passport number?",
"watermark": "wm_000000000004",
"text": null,
"citations": [],
"conflicts": [],
"unknowns": [
"what is my passport number?"
],
"budget": {
"requested_tokens": 4000,
"estimated_tokens": 0,
"truncated": false
},
"cost": {
"generative_calls": 0,
"processing_units": 0.0
},
"memories": []
}
```
`unknowns` names the question that could not be answered. Nothing was invented, and nothing was
coerced to `false`, `0`, or `""` — "we have no evidence" and "it is not so" are different claims and
this API never conflates them.
### No `self`, no possessive answer
Create a second Brain, omitting `self`, and give it the **same record**:
```bash
curl -s -X POST $BRAIN/brains \
-H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \
-d '{"id":"anon"}'
curl -s -X POST $BRAIN/brains/anon/records \
-H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \
-d '{"content":{"kind":"message","text":"my home address is 12 Analytical Way, Turin","speaker":"Ada Lovelace","thread":"sms:+15550100"}}'
```
Now ask the question that worked a moment ago:
```bash
curl -s -X POST $BRAIN/brains/anon/context \
-H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \
-d '{"query":"what is my home address?"}'
```
```json
{
"id": "ctx_01KYJE7P16KWMZ8AYD5GRRC0XR",
"object": "context",
"status": "ready",
"query": "what is my home address?",
"watermark": "wm_000000000006",
"text": null,
"memories": [],
"citations": [],
"conflicts": [],
"unknowns": [
"ownership of: what is my home address?"
],
"abstained": {
"reason": "no_attribution"
},
"budget": {
"requested_tokens": 4000,
"estimated_tokens": 0,
"truncated": false
},
"cost": {
"generative_calls": 0,
"processing_units": 0.0
}
}
```
Same evidence, same question, no answer — because nobody said whose Brain this is. The unknown is
not the address, it is the **ownership** of the question, and the API says so.
This is the guarantee: a Memory carries `owner: {state: self | other | unresolved}`, and
`unresolved` abstains rather than guesses. Somebody else's address in your inbox is not your
address, and a system that returns it as yours is confidently wrong — the failure that costs a
customer, and the one nobody else's scoreboard has a column for. `abstained.reason` is one of
`no_evidence · conflicting_evidence · no_attribution · out_of_policy`, so you can branch on which
kind of silence you got.
Pass `self` at create time and the whole class disappears. It is one field.
---
## 6. Read your own write
Every write returns a `Brain-Watermark`:
```bash
curl -s -D- -o /dev/null -X POST $BRAIN/brains/ada/records \
-H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \
-d '{"content":"I switched to a standing desk"}' | grep -i -E "^(HTTP|brain-watermark)"
```
```
HTTP/1.1 202 Accepted
brain-watermark: wm_000000000007
```
Send it back on the next Context and the response is guaranteed to be at or after that boundary.
If the server cannot serve it yet you get `202` with `status: "processing"` — never older evidence
labelled current:
```bash
curl -s -w "\nHTTP %{http_code}\n" -X POST $BRAIN/brains/ada/context \
-H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \
-H "Brain-Watermark: wm_000000009999" \
-d '{"query":"what is my home address?"}'
```
```
{"id":"ctx_01KYJE7P305B6PH67CCYHD7H33","object":"context","status":"processing","query":"what is my home address?","watermark":"wm_000000000007","citations":[],"unknowns":["what is my home address?"],"budget":{"estimated_tokens":0,"truncated":false}}
HTTP 202
```
Retry until `status` is `ready`. Omit the header and you get the freshest thing available, which is
the right default for reads that are not chasing a write you just made.
---
## 7. Retry without fear
Replay step 3 verbatim — same `Idempotency-Key`, same body:
```json
{
"id": "rec_01KYJE7NQ44HR4RJ31VDV0DKKE",
"object": "record",
"status": "ready",
"occurred_at": "2026-07-27T18:43:42Z",
"visibility": "shareable",
"memory_ids": [
"mem_01KYJE7NQ4Y35T92H30MKDPQFD"
],
"created_at": "2026-07-27T18:43:42Z"
}
```
The **original** Record, same id, no second Memory. Send a key on every write from a retrying
client; on the spine it is a `UNIQUE` constraint, so the database is the lock and there is no window
where two retries both win.
---
## 8. See what the system inferred
```bash
curl -s "$BRAIN/brains/ada/memories?limit=1" -H "Authorization: Bearer $KEY"
```
```json
{
"data": [
{
"id": "mem_01KYJE7NQ4Y35T92H30MKDPQFD",
"object": "memory",
"statement": "my home address is 12 Analytical Way, Turin",
"subject": "record",
"owner": {
"state": "self",
"entity": "Ada Lovelace"
},
"valid_from": "2026-07-27T18:43:42Z",
"valid_to": null,
"version": 1,
"status": "active",
"citations": [
{
"record_id": "rec_01KYJE7NQ44HR4RJ31VDV0DKKE",
"quote": "my home address is 12 Analytical Way, Turin"
}
]
}
],
"has_more": true,
"next_cursor": "mem_01KYJE7NRHXP1D8HR3GE856Y39"
}
```
`citations` is never empty: a Memory that cannot name its evidence is not written. `valid_to: null`
means current — supersession writes a new version and keeps the old one, so history is never
overwritten. Page with `?cursor=<next_cursor>`.
Four layers, and no layer silently acquires the authority of the layer after it:
```
Record = what entered Memory = what the system inferred
Object = what YOUR APP accepted (POST /objects — the only place authority moves; a model may never call it)
Context = what is eligible for this task
```
---
## 9. When something goes wrong
One shape, every failure, no exceptions — `{type, code, message, request_id, docs_url}`:
```
$ curl -s $BRAIN/brains/ada # no key
{"type":"authentication","code":"invalid_api_key","message":"expected `sk_<test|live>_<project>_<secret>`",...} 401
$ ... -d '{"content":{"kind":"note","text":"hello"}}' # unknown kind
{"type":"invalid_request","code":"invalid_request_body","message":"content: Input tag 'note' found using 'kind' does not match any of the expected tags: 'message', 'document', 'event', 'fact'",...} 400
$ ... -d '{"content":"hello","visibility":"pubic"}' # typo in an enum
{"type":"invalid_request","code":"invalid_request_body","message":"visibility: Input should be 'local_only', 'redacted_summary' or 'shareable'",...} 400
$ curl -s $BRAIN/brains/ada -H "Authorization: Bearer sk_test_someoneelse_zzz"
{"type":"not_found","code":"brain_not_found","message":"no Brain 'ada'",...} 404
$ curl -s -X POST $BRAIN/brains -d '{"id":"ada"}' ... # already exists
{"type":"conflict","code":"brain_exists","message":"Brain 'ada' exists",...} 409
```
`type` is the branch you write code against — `invalid_request · authentication · permission ·
not_found · conflict · rate_limit · policy · internal`. `request_id` is what a support ticket
quotes. A typo'd query parameter is refused rather than ignored (`?limitt=5` → `400
unknown_query_parameter`), because a bound you asked for and did not get is worse than an error.
Note the fourth one: another project's key gets `404`, not `403`. A different tenant is not told
that `ada` exists.
---
## The same thing in TypeScript
No dependencies — `fetch` is built in. Run it with `node quickstart.ts`: Node 24 strips the types
itself, no flags and no build step (that is what was run below). On older Node, drop the two type
annotations and save it as `.mjs`.
```ts
const BASE = "http://localhost:8788/v1";
const KEY = "sk_test_quickstart_ada";
async function brain(path: string, init: RequestInit = {}): Promise<any> {
const res = await fetch(BASE + path, {
...init,
headers: { Authorization: `Bearer ${KEY}`, "Content-Type": "application/json", ...init.headers },
});
const body = await res.json();
// Every failure is the same shape, so there is one error branch to write, not one per endpoint.
if (!res.ok) throw new Error(`${body.type}/${body.code}: ${body.message}`);
return body;
}
const post = (path: string, payload: unknown) =>
brain(path, { method: "POST", body: JSON.stringify(payload) });
// 1. A Brain, WITH self. Omit `self` and every possessive question below abstains.
await post("/brains", { id: "ada-ts", self: { type: "person", name: "Ada Lovelace" } });
// 2. Remember. A typed record keeps the speaker and the thread; a string would lose both.
await post("/brains/ada-ts/records", {
content: {
kind: "message",
text: "my home address is 12 Analytical Way, Turin",
speaker: "Ada Lovelace",
thread: "sms:+15550100",
},
});
// 3. Context — cited, budgeted, and honest when it has nothing.
const ctx = await post("/brains/ada-ts/context", {
query: "what is my home address?",
include: ["citations", "memories"],
});
console.log("text: ", ctx.text);
console.log("abstained: ", ctx.abstained ?? null);
console.log("unknowns: ", ctx.unknowns);
for (const c of ctx.citations) console.log("cited: ", c.record_id, "|", c.quote);
console.log("generative calls:", ctx.cost.generative_calls);
```
```
$ node quickstart.ts
text: my home address is 12 Analytical Way, Turin
abstained: null
unknowns: []
cited: rec_01KYJE930AKFMCK6BEDPB7JQXX | my home address is 12 Analytical Way, Turin
generative calls: 0
```
---
## The same thing in Python
Standard library only, so there is nothing to install. `httpx` or `requests` work the same way.
```python
import json
import urllib.error
import urllib.request
BASE, KEY = "http://localhost:8788/v1", "sk_test_quickstart_ada"
def brain(path, payload=None, method="GET"):
req = urllib.request.Request(
BASE + path, method=method,
data=json.dumps(payload).encode() if payload is not None else None,
headers={"Authorization": f"Bearer {KEY}", "Content-Type": "application/json"})
try:
with urllib.request.urlopen(req) as res:
return json.load(res)
except urllib.error.HTTPError as exc:
# Every failure is the same shape, so there is one error branch to write, not one per call.
err = json.load(exc)
raise SystemExit(f"{err['type']}/{err['code']}: {err['message']}")
def post(path, payload):
return brain(path, payload, "POST")
# 1. A Brain, WITH self. Omit `self` and every possessive question below abstains.
post("/brains", {"id": "ada-py", "self": {"type": "person", "name": "Ada Lovelace"}})
# 2. Remember. A typed record keeps the speaker and the thread; a string would lose both.
post("/brains/ada-py/records", {"content": {
"kind": "message",
"text": "my home address is 12 Analytical Way, Turin",
"speaker": "Ada Lovelace",
"thread": "sms:+15550100",
}})
# 3. Context — cited, budgeted, and honest when it has nothing.
ctx = post("/brains/ada-py/context", {
"query": "what is my home address?",
"include": ["citations", "memories"],
})
print("text: ", ctx["text"])
print("abstained: ", ctx.get("abstained"))
print("unknowns: ", ctx["unknowns"])
for c in ctx["citations"]:
print("cited: ", c["record_id"], "|", c["quote"])
print("generative calls:", ctx["cost"]["generative_calls"])
```
```
$ python quickstart.py
text: my home address is 12 Analytical Way, Turin
abstained: None
unknowns: []
cited: rec_01KYJE932SGWXWC0ZQFDNZ4FCW | my home address is 12 Analytical Way, Turin
generative calls: 0
```
---
## What the local server will not do for you
So you do not mistake the reference for the engine:
- **Retrieval is a substring scan.** Your question must share a word with the evidence. `"where do
I live?"` misses the address record on this server and returns `unknowns` — verified. The lanes
that make that question work (state, vector, person, graph, fused) are the engine's, not the
contract's.
- **One Memory per Record, `subject: "record"`.** No extraction, no entity resolution, no
supersession, no conflicts.
- **`include: ["explain"]` now returns a real lane breakdown — the fixture emits it honestly (it was empty until the trace work landed, so an older copy of this doc said otherwise).** The contract declares it; this store does not
implement it.
- **Nothing is durable.** Restart the process and you have an empty server.
- **`mode: "thorough"` is accepted and returns exactly what `fast` returns.** ~~and then reports
`cost.generative_calls: 1` — a call it did not make.~~ *(Fixed 2026-07-29: the fixture used to
report a placeholder `1` for `thorough` while making no call at all. It now reports
`model_calls_total: 0 · embedding_calls: 0 · generative_calls: 0` on **both** modes, which is the
truth about a store that calls no model — verified by running it.)* **Do not calibrate cost
against this server either way:** on the spine the two modes genuinely differ, `fast` spending one
embedding and `thorough` adding a generative query rewrite.
What it *does* do is the whole reason it exists: it makes the guarantees testable without a
database, and it is what the conformance fuzzer runs against.
---
## Next
- [`openapi.yaml`](./openapi.yaml) — the contract, and the source of truth. Not the server's
generated schema.
- [`INTEGRATION_GUIDE.md`](./INTEGRATION_GUIDE.md) — three real applications on the same five
resources, with no per-customer branch anywhere.
- [`BACKLOG_10X.md`](./BACKLOG_10X.md) — what is being built next, and the measurement behind each.
- The seven read knobs you have not touched yet: `purpose · as_of · budget · include · schema ·
filter · mode`. You reached a cited answer without any of them, which is the point.
---
## Verified
Every command and response above was executed on 2026-07-27 against
`.venv/bin/python -m uvicorn memory_api.app:app --port 8788` (`InMemoryStore`, Python 3.13.13).
Nothing here is illustrative.
- **Mechanically re-checked.** Every command on this page was re-run in order against a freshly
started server and each of the seven pasted JSON responses was diffed against the live one.
All seven match once generated ids and timestamps are normalised — that is the only thing
that differs between runs.
- The whole curl sequence takes **about a second** of machine time. The ten minutes is you reading.
- **The bare-install path works and has a floor.** `python3.13 -m venv` + `pip install fastapi
uvicorn` (fastapi 0.140.7 / pydantic 2.13.4 / uvicorn 0.51.0, no repo dependencies) served the
same cited Context. The same venv on **Python 3.9 fails to start** —
`Unable to evaluate type annotation 'str | None'`. The package declares `requires-python >=3.12`.
- The TypeScript file ran on Node v24.18.0 via `node quickstart.ts`, no flags, no packages;
the type-stripped `.mjs` variant ran too. The Python file ran on 3.13.13, standard library only.
- Every claim in prose was checked the same way, including the ones that make this page look worse:
the `"where do I live?"` miss, `include: ["explain"]` returning nothing, and `thorough`'s
fabricated `generative_calls: 1`.
- Gates on this code: `pytest memory_api/tests/` 14 passed / 3 skipped ·
`scripts/check_api_simplicity.py` **GREEN** · schemathesis against the checked-in contract,
3 known findings — **identical before and after** the fixes this page's first run produced.