Docs / Quickstart

Quickstart

From nothing to cited Context in two calls.

TypeScript
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"] })
});
code
{
    "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"
                }
            ]
        }
    ]
}
→ context text→ source records → unknowns→ conflicts→ visible cost

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

code
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:

cURL
cd ~/Documents/brainComputer/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:

cURL
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

cURL
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

cURL
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}:

kindrequiredkeeps
messagetextspeaker, thread — a thread is what a session IS here; you never create one
documenttexttitle
eventtitlestarts_at, ends_at, participants, place
factsubject, predicate, valuenothing 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
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:

cURL
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
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

cURL
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:

cURL
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
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:

cURL
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)"
code
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:

cURL
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?"}'
code
{"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

cURL
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:

code
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}:

code
$ 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=5400 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);
code
$ 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"])
code
$ 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 — 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 startUnable 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.