# 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.
