# API

> REST API reference.

Canonical page: https://xmemory.ai/api/

The xmemory REST API is what the [Python](https://xmemory.ai/python/) and [TypeScript](https://xmemory.ai/typescript/) SDKs call under the hood. You can also call it directly with `curl`, `fetch`, or any HTTP client.

For MCP-based access (no HTTP calls needed), see the [MCP](https://xmemory.ai/mcp/) guide.

All endpoints live under `https://api.xmemory.ai`, accept JSON, and return JSON. Every request except `/healthz` must send your xmemory API key as a Bearer token: `Authorization: Bearer <your API key>`.

Get your API key from the [xmemory console](https://console.xmemory.ai). Copy and securely store it — never share your API key publicly.

---

## Response envelope

Every endpoint wraps its result in a standard envelope:

```json
{
  "ids": ["<created-resource-id>"],
  "items": [ { "…": "the actual payload" } ],
  "errors": [],
  "console_url": "https://console.xmemory.ai/…"
}
```

On a **successful** response, the per-endpoint shapes documented below (`write_id`, `reader_result`, `data_schema`, …) are the object inside **`items[0]`** — a raw HTTP caller must read them there, not at the top level. `ids` collects created resource IDs, `errors` any non-fatal errors, and `console_url` links to the relevant view in the [console](https://console.xmemory.ai). (The [Python](https://xmemory.ai/python/) and [TypeScript](https://xmemory.ai/typescript/) SDKs unwrap this for you.) Schema-evolution endpoints report failures differently: they return a structured error body at the top level rather than in this envelope (see [Structured errors](https://xmemory.ai/api/#structured-errors)).

---

## Try it

Write something, then read it back — two `curl` calls:

```bash
# Write
curl -X POST https://api.xmemory.ai/instances/$XMEM_INSTANCE_ID/write \
  -H "Authorization: Bearer $XMEM_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "text": "Alice Johnson works at Acme Corp. Her email is alice@acme.com.",
    "extraction_logic": "deep"
  }'

# Read
curl -X POST https://api.xmemory.ai/instances/$XMEM_INSTANCE_ID/read \
  -H "Authorization: Bearer $XMEM_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "query": "What is Alice'\''s email?"
  }'
# → {"ids": [], "items": [{"reader_result": {"answer": "alice@acme.com"}}], "errors": [], "console_url": "…"}
```

The rest of this page covers every endpoint in detail. The data-plane endpoints (write, read, extract, describe) come first; if you don't have an instance yet, jump to [Clusters](#clusters) and [Instances](#instances) to create one.

---

## Writing

Send raw text; xmemory extracts typed objects from it and persists them. `write` blocks until committed; `write_async` returns immediately and you poll `write_status` for completion. Both endpoints alternatively accept `structured_mutations` for deterministic, extraction-free edits — see [Structured writes](#structured-writes) — and both accept a `scope` that anchors a text write to records you already know about, see [Scoped writes](#scoped-writes).

**`POST /instances/{instance_id}/write`** — Persist data — extract structured objects from text, or apply explicit structured mutations (blocks until committed)

Parameters:
- `text` (`string`) — Free-form text to extract from. Exactly one of `text` / `structured_mutations` must be provided.
- `structured_mutations` (`array`) — Ordered list of deterministic, LLM-free object create/update/delete and relation create/delete mutations — see [Structured writes](#structured-writes). Mutually exclusive with `text`.
- `extraction_logic` (`string`) — `"fast"` (default) or `"deep"`. Text writes only. Defaults to `"fast"`.
- `trace_id` (`string`) — Caller-supplied ID echoed back on the response for log correlation.
- `session_id` (`string`) — Session ID for grouping related calls in tracing.
- `use_diff_engine` (`boolean`) — Override the instance's diff-engine setting for this write. Text writes only.
- `scope` (`object`) — Anchor a text write to specific existing records: `{ "objects": [...], "mode": "reject" | "drop" }`, each entry `{ "type": ..., "key": { "key": { <pk field>: <value> } } }`, naming one record by its user-defined primary key. The write is confined to them. In the default `"reject"` mode a change to any other existing record fails the whole write; in `"drop"` mode those changes are skipped and reported under `changes.skipped_out_of_scope`, only records the scope names may be created, and a named record need not be stored yet. Extraction is unchanged by a scope, so the text still names the records it means. Text writes only, and it additionally requires read permission on the instance. See [Scoped writes](https://xmemory.ai/api/#scoped-writes).

Returns:
- `write_id` (`string`) — ID of the committed write.
- `cleaned_objects` (`object`) — The stored objects, as `{ objects: [...], relations: [...] }`.
- `changes` (`object`) — What the write did, grouped into `created` / `updated` / `deleted` (each `{ objects: [...], relations: [...] }`; updated object entries carry per-field old/new values). A drop-mode scoped write adds `skipped_out_of_scope`, listing what it left out by operation, object type, identity, field names and record count — see [What a drop-mode write skipped](https://xmemory.ai/api/#what-a-drop-mode-write-skipped).
- `trace_id` (`string`) — Echoed trace id for log correlation.

Example response:
```json
{
  "write_id": "...",
  "cleaned_objects": { "objects": [], "relations": [] },
  "changes": { "created": { "objects": [], "relations": [] }, "updated": { "objects": [], "relations": [] }, "deleted": { "objects": [], "relations": [] } },
  "trace_id": "..."
}
```

**`POST /instances/{instance_id}/write_async`** — Same write pipeline as write, but returns immediately instead of blocking

Parameters:
- `text` (`string`) — Free-form text to extract from. Exactly one of `text` / `structured_mutations` must be provided.
- `structured_mutations` (`array`) — Ordered list of deterministic, LLM-free create/update/delete mutations — see [Structured writes](#structured-writes). Mutually exclusive with `text`.
- `extraction_logic` (`string`) — `"fast"` (default) or `"deep"`. Text writes only. Defaults to `"fast"`.
- `trace_id` (`string`) — Caller-supplied ID echoed back for log correlation.
- `session_id` (`string`) — Session ID for grouping related calls in tracing.
- `use_diff_engine` (`boolean`) — Override the instance's diff-engine setting for this write. Text writes only.
- `scope` (`object`) — Anchor a text write to specific existing records: `{ "objects": [...], "mode": "reject" | "drop" }`, each entry `{ "type": ..., "key": { "key": { <pk field>: <value> } } }`, naming one record by its user-defined primary key. The write is confined to them. In the default `"reject"` mode a change to any other existing record fails the whole write; in `"drop"` mode those changes are skipped and reported under `changes.skipped_out_of_scope`, only records the scope names may be created, and a named record need not be stored yet. Extraction is unchanged by a scope, so the text still names the records it means. Text writes only, and it additionally requires read permission on the instance. See [Scoped writes](https://xmemory.ai/api/#scoped-writes).

Returns:
- `write_id` (`string`) — ID to poll with `writeStatus`. The write is queued behind every earlier write to the instance and applied after them, in order — see [Write ordering](#write-ordering). Do not read immediately after an async write — the data may not be committed yet.

Example response:
```json
{ "write_id": "..." }
```

**`POST /instances/{instance_id}/write_status`** — Check whether an async write has finished

Parameters:
- `write_id` (`string`, required) — ID returned by `writeAsync`.

Returns:
- `write_status` (`string`) — One of `queued`, `processing`, `completed`, `failed`, `not_found` — plus the intermediate two-phase-pipeline states `extracting`, `extracted`, `applying`. Treat anything other than `completed` / `failed` / `not_found` as still in flight.
- `error_detail` (`string`) — Human-readable failure detail when `write_status` is `failed`; otherwise null.
- `error` (`object`) — Structured error (`code`, `message`) when the write failed; otherwise null.
- `completed_at` (`string`) — ISO-8601 timestamp when the write finished; null until then.

Example response:
```json
{
  "write_id": "...",
  "write_status": "completed",
  "error_detail": null,
  "error": null,
  "completed_at": "2025-03-16T12:34:56Z"
}
```

### Write ordering

Every write to an instance goes into one queue for that instance: `write` and `write_async`, text and `structured_mutations` alike. Queued writes are processed in parallel, but applied to the instance one at a time, in the order they were queued (FIFO). A write is never applied ahead of an older one, even when its own processing finished first.

- `write_async` returns once the write is queued. `write_status` reports `queued` until processing starts, and `extracted` while a processed write waits for older writes to be applied.
- `write` waits in the same queue and returns once its own write is applied. It does not jump ahead of async writes queued before it.
- The order is the order the server receives the writes. Two requests sent at the same time have no defined order between them, so when one write must apply after another, wait for the first response (for `write_async`, the `write_id`) before sending the second.
- Reads are not queued: a read sees only writes that have completed.

### Structured writes

When you already know exactly what to store, skip extraction entirely: pass `structured_mutations` instead of `text` (exactly one of the two — the request is rejected if both or neither are present). Each mutation is applied deterministically, with no LLM involved, making structured writes fast and exactly repeatable.

A mutation is tagged `object_mutation` or `relation_mutation`. Objects support `create`, `update`, and `delete`; relations support endpoint-based `create` and `delete`:

```bash
curl -X POST https://api.xmemory.ai/instances/$XMEM_INSTANCE_ID/write \
  -H "Authorization: Bearer $XMEM_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "structured_mutations": [
      {
        "object_mutation": {
          "object_type": "person",
          "create": {
            "key": { "email": "alice@acme.com" },
            "values": { "name": "Alice Johnson" }
          }
        }
      },
      {
        "relation_mutation": {
          "relation_type": "works_at",
          "create": {
            "endpoints": [
              { "object_name": "person", "key": { "email": "alice@acme.com" } },
              { "object_name": "company", "key": { "name": "Acme Corp" } }
            ]
          }
        }
      }
    ]
  }'
```

**Addressing objects.** `key` identifies an object by its user-defined primary-key fields (as above) — or, for `update` / `delete`, by `{ "xuid": "..." }` (the server-generated unique ID returned in `changes`). `create` never takes an `xuid`; it is generated server-side.

**Object operations.**

- `create` — `key` (primary-key fields) plus `values` (the remaining fields).
- `update` — `key` plus `values`; a `null` value **clears** that field.
- `delete` — `key` only.

**Relation operations.**

- `create` — `endpoints` (one per relation role: `object_name` + that object's `key`).
- `delete` — match by `endpoints` (a subset is allowed) or `key`; deleting more than one matched row requires `"allow_bulk_delete": true`.

The endpoint keys above are a caller-addressing mechanism for deterministic structured mutations,
not an XMD schema requirement. Natural-language extraction can create relations between objects
with `primary_key: []`; xmemory links those concrete records internally. Do not add an application
key or generated UUID merely to enable relation extraction.

XMD v1 relations do not have their own scalar fields. When a relationship needs data such as a role, quantity, or date, model that relationship as an object and connect it to the participants with relations.

**Ordering and batches.** Mutations apply in list order, and later mutations may reference objects created earlier in the same batch (e.g. create a `person`, then a relation pointing at it). Multiple mutations of the *same object* in one batch follow a strict contract: compatible sequences compose (create then update applies the update to the created row; update then delete nets out to a delete; duplicate deletes are no-ops), while contradictory ones — create then delete, or anything after a delete — are rejected with a "split into separate writes" error rather than silently reordered.

**Response.** The same shape as a text write; `changes` reports exactly what was `created` / `updated` / `deleted`, with per-field old/new values on object updates. `write_async` accepts `structured_mutations` identically — poll `writeStatus` and read `changes` from the completed status.

**Availability.** Structured writes can be disabled per deployment; when off, the request fails with `"Structured writes are not enabled on this deployment."`

---

## Reading

Ask a question in natural language — xmemory searches the instance's stored objects and returns a synthesized answer.

**`POST /instances/{instance_id}/read`** — Query the instance in natural language

Parameters:
- `query` (`string`, required) — Natural-language question.
- `mode` (`string`) — `"single-answer"` (default), `"xresponse"`, or `"raw-tables"`. Defaults to `"single-answer"`.
- `scope` (`object`) — Restrict the read to specific objects: `{ "objects": [...], "relations_scope": "no_relations" | "all_relations" }`. Each entry names one object by `type` plus `{ "key": { <pk field>: <value> } }`, its user-defined primary key. Defaults to `no_relations` (objects only). See [Scoped reads](https://xmemory.ai/api/#scoped-reads).
- `return_sql` (`boolean`) — Include the executed SQL in the response (`sql`). Defaults to `false`.
- `include_related_types` (`string`) — `"types"` adds `related_types` to the response: the object types the read touched, each with the fields it did not return and the object types a declared relation links it to, plus a catalog describing every named type and relation once. Schema-level only — no extra rows are read. Needs `instance.get_own` on the API key, the permission the schema endpoints need; refused with HTTP 403 otherwise. See [Related types](https://xmemory.ai/api/#related-types). Defaults to `"none"`.
- `related_types_depth` (`integer`) — With `include_related_types: "types"`, how many relation levels the catalog follows out from the touched types, 1 to 3. Every `objects` entry then carries its `distance` and, below the last level, its own `related` edges; the response echoes the depth asked for as `depth` and counts the types the budgets kept out of the catalog on `omitted_objects`. The default serves the payload exactly as without the field. Out of range is HTTP 422. See [Following relations further](https://xmemory.ai/api/#following-relations-further). Defaults to `1`.
- `skip_suggestion_capture` (`boolean`) — Skip recording read-gap signals for the schema-suggestion engine. Defaults to `false`.
- `trace_id` (`string`) — Caller-supplied ID echoed back for log correlation.
- `session_id` (`string`) — Session ID for grouping related calls in tracing.

Returns: The `reader_result` shape depends on the mode:

| Mode | `reader_result` | Best for |
|---|---|---|
| `single-answer` | `{"answer": "Bob Lee joined last Monday."}` | Natural-language answers |
| `xresponse` | `{"objects": [...], "relations": [...]}` | Structured data |
| `raw-tables` | `{"columns": [{"name": ..., "type": ...}], "rows": [[...]]}`, or `{"columns": [], "rows": []}` when the read matched no rows | Raw SQL results |

A `raw-tables` read that matched nothing reports it the same way everywhere — as
`{"columns": [], "rows": []}` at the top level and in every `reader_results` entry — so one
emptiness check works against the whole response.

`null` is a different answer, not a variant of that one: the schema provably cannot
represent the concept you asked about. Read it the way you would read "is not tracked in
this memory" — the data is not merely absent, this memory cannot hold it — and try a
better-matching instance if you have one. `xresponse` draws the same line, with
`{"objects": [], "relations": []}` as its empty result. A read whose query could not be
executed fails rather than returning either.

Also returned: `sql` (the executed SQL, when `return_sql: true`),
`related_types` (the schema around the answer, when `include_related_types: "types"` —
see [Related types](https://xmemory.ai/api/#related-types)),
`pending_suggestions` (count of pending schema improvements), and `trace_id`.

**Composite questions.** A question holding several independent parts is split
server-side into sub-questions, one answered per part. The response then carries
`reader_results` — one entry per sub-question, each
`{ sub_query, reader_result, error }`, with every `reader_result` in the requested
mode; the top-level `reader_result` stays the combined value. The key is absent for a
single-intent question, so treat its presence, not its length, as the signal that the
question was split. A sub-question that could not be answered sets its own `error` and
leaves the others intact. `sqls` carries the matching per-sub-question SQL and is gated
exactly like `sql`.

---

## Related types

A read can also say what else the memory could answer about. Pass `include_related_types: "types"` and the response carries `related_types`: the object types the read touched, each with the fields the read did not return and the object types a declared relation links it to, plus a catalog describing every named type and relation once. It is derived from the instance schema and the statements the read executed — no extra rows, no model call — so an agent can ask a deliberate follow-up instead of guessing what the store holds.

```bash
curl -X POST https://api.xmemory.ai/instances/$XMEM_INSTANCE_ID/read \
  -H "Authorization: Bearer $XMEM_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "query": "Which courses require an English test?",
    "include_related_types": "types"
  }'
```

The response item then carries `related_types` next to `reader_result`:

```json
{
  "reader_result": { "answer": "CS101 and CS205 require an English language test." },
  "related_types": {
    "touched": [
      {
        "object_type": "course",
        "fields_not_returned": ["credits", "faculty"],
        "related": [
          { "object_type": "university", "relation": "offering", "touched_role": "course", "related_role": "university", "cardinality": "many_to_many" }
        ],
        "omitted_related": 0
      }
    ],
    "objects": {
      "course": { "description": "An academic programme", "primary_key": ["code"], "fields": ["code", "credits", "faculty", "name"] },
      "university": { "description": "A higher education institution", "primary_key": ["code"], "fields": ["city", "code", "name"] }
    },
    "relations": { "offering": { "description": "A university's offering of a course" } },
    "omitted_touched": 0,
    "truncated": false
  }
}
```

### What each part means

- `touched` — one entry per object type the read's SQL read from. `fields_not_returned` lists the fields of that type the read did not project, so a follow-up can ask for one by name. `related` lists one edge per declared relation the type takes part in: the neighbouring type, the relation, the role each side plays, and the cardinality seen from the touched side — `one_to_one`, `one_to_many`, `many_to_one`, `many_to_many`, or `unconstrained` when the relation declares no unique key (see [Relations](https://xmemory.ai/xmd/#relations)). A self-relation lists one edge per role.
- `objects` and `relations` — the catalog: every type a listed edge names, described once (description, primary key, field names), and every relation named by an edge, once. With `related_types_depth` above 1 each entry also carries its `distance` and, below the last level, its own `related` edges (see below).
- `omitted_touched`, `omitted_related`, `truncated` — the server caps touched types, edges per type, edges overall and serialized size; every drop is counted on the entry it came from, and `truncated` says that something was dropped.

The key is present only when the request asked for it; `touched` is empty when no executed statement read a declared object type. The option composes with `mode` and `scope`, and a scoped read still lists neighbouring types outside its scope, since those are schema names the caller could read from the schema endpoint anyway.

### Following relations further

`related_types_depth` (1 to 3, default 1) follows the listed edges further than the touched types' own. Level 1 is the types those edges name; each further level expands the previous one, so every entry under `objects` carries its `distance` — listed edges from the nearest touched type: 0 for a touched type, 1 for a type a touched type's listed edge names, and so on — and an entry between the touched types and the last level carries its own `related` edges, so the walk can be continued from the catalog. The response echoes the depth asked for as `depth` and counts on `omitted_objects` every type within that many relation levels in the schema that the catalog does not list.

```json
{
  "query": "Which courses require an English test?",
  "include_related_types": "types",
  "related_types_depth": 2
}
```

```json
"related_types": {
  "depth": 2,
  "touched": [ { "object_type": "course", "fields_not_returned": ["credits", "faculty"], "related": [ { "object_type": "university", "relation": "offering", "touched_role": "course", "related_role": "university", "cardinality": "many_to_many" } ], "omitted_related": 0 } ],
  "objects": {
    "course": { "distance": 0, "description": "An academic programme", "primary_key": ["code"], "fields": ["code", "credits", "faculty", "name"], "related": [], "omitted_related": 0 },
    "university": {
      "distance": 1, "description": "A higher education institution", "primary_key": ["code"], "fields": ["city", "code", "name"],
      "related": [ { "object_type": "event", "relation": "university_event", "touched_role": "university", "related_role": "event", "cardinality": "unconstrained" } ],
      "omitted_related": 0
    },
    "event": { "distance": 2, "description": "An event a university hosts", "primary_key": ["name", "date"], "fields": ["date", "name"], "related": [], "omitted_related": 0 }
  },
  "relations": { "offering": { "description": "A university's offering of a course" }, "university_event": { "description": "An event hosted by a university" } },
  "omitted_touched": 0,
  "omitted_objects": 0,
  "truncated": false
}
```

On an edge, `touched_role` is the role the entry's own type plays — the touched type on a `touched` entry, the catalog type on an `objects` entry — and `cardinality` is read from that role. The catalog's edges share the server's edge budget with the touched entries and each entry is under the same per-type cap, so a route through an edge the budget cut is not followed: a type behind it is listed further away than the schema puts it, or not at all, and the cutting entry's `omitted_related` says where the cut was (an entry the budget starved lists no edges and counts all of them). `depth` is never lowered; a level the byte budget drops joins `omitted_objects`, and `truncated` is set whenever anything was cut. A request for one level is served in the shape shown above, byte for byte: `depth`, `omitted_objects` and the entries' `distance`, `related` and `omitted_related` are written only for a deeper request, which a client built before the depth cannot send. A value outside 1 to 3 is HTTP 422, `code` `VALIDATION_ERROR`, with `field` naming `related_types_depth`; the depth is ignored unless `include_related_types` is `"types"`.

### Permissions

Related types are schema metadata — the same information `GET /instances/{id}/schema` and `/describe` carry — so an API key needs `instance.get_own` in addition to `data.read` for the option. A key without it is refused with HTTP 403, `code` `FORBIDDEN`, and a message that names the permission; the plain read is unaffected. `related_types_depth` needs nothing beyond the option.

---

## Composite questions

A question holding several independent parts — "who owns the billing service, and when did the last incident close?" — is answered in one call. The server splits it into sub-questions, answers each on its own, and runs them against one shared snapshot, so the parts cannot disagree with each other. Nothing has to be enabled or passed.

The response then carries `reader_results` alongside the usual `reader_result`, with one entry per sub-question:

```json
{
  "reader_result": { "answer": "Platform owns billing. The last incident closed on 3 March." },
  "reader_results": [
    { "sub_query": "who owns the billing service?", "reader_result": { "answer": "Platform." }, "error": null },
    { "sub_query": "when did the last incident close?", "reader_result": { "answer": "3 March." }, "error": null }
  ]
}
```

Each entry's `reader_result` is in whatever `mode` you asked for, so a `raw-tables` composite question returns one table per part. The top-level `reader_result` stays the combined answer, so a client that ignores `reader_results` keeps working.

Two things to code against. The key is **absent** for a single-intent question rather than being a one-entry list, so check whether it is present rather than counting its length. And a part that could not be answered sets its own `error` and leaves the other answers intact, so a composite read can be partly successful — handle `error` per entry rather than assuming the whole call failed.

A `scope`, if you supply one, applies read-only to every sub-question. `sqls` carries the per-sub-question SQL, gated exactly like `sql`.

---

## Scoped reads

Restrict a query to a specific set of objects in your instance, limiting data access to only what you specify. Useful for multi-tenant systems, privacy controls, and compliance requirements.

### Request format

Add a `scope` field to the `/read` endpoint:

```json
{
  "query": "What is their email?",
  "scope": {
    "objects": [
      {
        "type": "Person",
        "key": {"key": {"email": "alice@example.com"}}
      }
    ],
    "relations_scope": "no_relations"
  }
}
```

### Scope structure

#### `objects` (required)

List of one or more objects to query. Each object has:

- `type` (string): The object type (e.g., `"Person"`, `"Order"`).
- `key` (object): The object's user-defined primary key, nested as
  `{"key": {"email": "alice@example.com"}}`, with one entry for every primary-key field. Only objects
  of a type that declares a primary key can be scoped.

#### `relations_scope` (optional, default: `"no_relations"`)

Control whether to include relations that these objects are part of:

- `"no_relations"`: Only the specified objects.
- `"all_relations"`: Include relations these objects are part of.

### Examples

#### Query a single object

```bash
curl -X POST https://api.xmemory.ai/instances/your-instance-id/read \
  -H "Authorization: Bearer $XMEM_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "query": "What is their email and phone number?",
    "scope": {
      "objects": [
        {"type": "Person", "key": {"key": {"name": "Alice Johnson"}}}
      ]
    }
  }'
```

#### Query multiple objects with relations

```bash
curl -X POST https://api.xmemory.ai/instances/your-instance-id/read \
  -H "Authorization: Bearer $XMEM_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "query": "Which projects do these people work on together?",
    "scope": {
      "objects": [
        {"type": "Person", "key": {"key": {"email": "alice@example.com"}}},
        {"type": "Person", "key": {"key": {"email": "bob@example.com"}}}
      ],
      "relations_scope": "all_relations"
    }
  }'
```

**Semantics:** The scope is limited to the two specified Person objects. With `"relations_scope": "all_relations"`, relations these Person objects are part of become visible.

### Records that are not there

A scope entry naming a record the instance does not hold is **not an error on a read**. It drops
out of the scope, and the read answers from whatever is left:

- Every entry unmatched — HTTP 200 with the empty result for the mode. `raw-tables` returns no
  rows, `xresponse` no objects, `single-answer` says nothing is stored for the records you scoped
  to.
- Some entries unmatched — the read answers from the records that do exist, as if only those had
  been named.

This is what lets you ask about a record before you know whether it is there. A read is a
question, and "nothing is stored under that key" answers it — the same way an unscoped read
answers when it finds nothing.

The trade-off is that **an aggregate is computed over the records that were found, not over the
entries you sent**. Scope two people, ask "how many?", and if one of them was deleted since you
last read it, the answer is `1` — correct for the scope that survived, and not what you meant to
ask. Nothing in the response reports the difference. When it matters that every entry resolved,
read the records back and check, rather than trusting a count over them.

### Error handling

A scope that cannot be *interpreted* is still rejected with HTTP 400, before anything runs:

```json
{
  "status": "error",
  "error_message": "Unknown scope object type 'Foo'; valid types are Person, Project"
}
```

| Cause | What it means |
|---|---|
| An unknown object type | The type is not in the instance schema. |
| A key that does not name the type's primary-key fields | A scope key must set every primary-key field and nothing else. |
| A value that cannot be coerced to its field's type | A scope key is an identifier, so a value that does not fit the field is a bad reference rather than a lookup that missed. |
| A key matching more than one record | Keys are matched on the same case- and accent-insensitive identity the writer uses, so two records stored as `José` and `Jose` can both answer to `jose`. Resolving to both would widen the scope to a record you did not name. |
| An empty `objects` list | A client error, not an unscoped read. Omit `scope` entirely for that. |
| Too many objects in one scope | Each deployment caps how many records a single scope may name. |

The distinction throughout is between a reference that is *absent* and one that is *malformed*.
Absence is an answer; a reference that cannot be resolved to exactly one record is not.

---

## Scoped writes

A write is normally free to touch anything in the instance: the extractor sees the text alone, and whatever it produces is reconciled against every record you have. That is the right default for "here is something new", and the wrong one for "here is more about *this* record" — a note about someone the instance already knows can just as easily land as a second, near-identical record.

A `scope` on `/write` or `/write_async` names the records the write is about, and confines the write to them. It may only modify or delete the scoped records, and create new records and relations anchored to them. Anything else is either refused or skipped, depending on the scope's `mode`.

Confinement is checked against the plan the write produces, not asked of the extractor. Extraction itself is unchanged by a scope: it runs on the text alone, exactly as for an unscoped write. So the text still has to name the records it means, as any other write's text does — a scope decides what may be written, not what is understood.

### Request format

Add a `scope` field alongside `text`:

```json
{
  "text": "After her promotion she is a surgeon, and her desk phone is +1-555-0100.",
  "scope": {
    "objects": [
      {
        "type": "Person",
        "key": {"key": {"name": "Alice Johnson"}}
      }
    ],
    "mode": "reject"
  }
}
```

### Scope structure

#### `objects` (required)

One or more existing records the write is anchored to. Each entry has:

- `type` (string): The object type, as the PascalCase class name (`"Person"`) or the snake_case table name (`"person"`).
- `key` (object): The record's user-defined primary key, nested as `{"key": {"name": "Alice Johnson"}}`, with one entry for every primary-key field. Only records of a type that declares a primary key can be scoped — a scope names records by that key, so a type declared `primary_key: []` has nothing to name its records by.

There is no `relations_scope` here, unlike a scoped read: relation *changes* are governed by the confinement rule rather than by a mode of their own.

#### `mode` (optional)

What the write does with a change that falls outside the scope. Defaults to `"reject"`.

- `"reject"`: the write fails as a whole on the first such change, and nothing is applied. This is the behaviour a scope has always had.
- `"drop"`: the out-of-scope changes are skipped and reported, and the rest of the write is applied. A change that only depended on a skipped one goes too. The write applies the plan it would have run unscoped minus those changes, and never adds a change that plan did not contain or aims one at a different record.

Drop mode is also stricter about creates, and that is what makes it useful for corrections: it creates **only** records whose primary key the scope names. A scope entry may name a record that is not stored yet, which is how you say "create or update exactly this record" — whichever it turns out to be. Because a record is named by its primary key, a type that declares none cannot be named, and drop mode never creates one.

Drop mode needs the diff engine, which is on by default; a write that disables it is refused. What was skipped comes back in [`changes.skipped_out_of_scope`](#what-a-drop-mode-write-skipped).

### Examples

#### Update a known record instead of duplicating it

```bash
curl -X POST https://api.xmemory.ai/instances/$XMEM_INSTANCE_ID/write \
  -H "Authorization: Bearer $XMEM_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "text": "After her promotion she is a surgeon, and her desk phone is +1-555-0100.",
    "scope": {
      "objects": [
        {"type": "Person", "key": {"key": {"name": "Alice Johnson"}}}
      ]
    }
  }'
```

**Semantics:** the text names Alice, so the write lands on her existing record: `role` and `phone` are updated rather than a second `Person` appearing. No other record in the instance can be touched by this write — in the default `reject` mode, a write that would touch one fails instead.

#### Scope everything the write is allowed to change

```bash
curl -X POST https://api.xmemory.ai/instances/$XMEM_INSTANCE_ID/write \
  -H "Authorization: Bearer $XMEM_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "text": "The Atlas migration finished on Tuesday and Bob Lee signed it off.",
    "scope": {
      "objects": [
        {"type": "Project", "key": {"key": {"name": "Atlas"}}},
        {"type": "Person", "key": {"key": {"name": "Bob Lee"}}}
      ]
    }
  }'
```

**Semantics:** Both records may be updated, and new records linked to either of them may be created. Had Bob Lee been left out of the scope, linking the project to his existing record would still be allowed but *changing* that record would not — in `reject` mode that failure is the whole write, and in `drop` mode the change to Bob is skipped and reported while the rest lands. Either way, anything the write should be able to edit belongs in the scope.

#### Correct one record and report the rest

```bash
curl -X POST https://api.xmemory.ai/instances/$XMEM_INSTANCE_ID/write \
  -H "Authorization: Bearer $XMEM_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "text": "Alice adopted a cat named Luna. Her old dog Rex is fine.",
    "scope": {
      "objects": [
        {"type": "Person", "key": {"key": {"name": "Alice Johnson"}}},
        {"type": "Pet", "key": {"key": {"name": "Luna"}}}
      ],
      "mode": "drop"
    }
  }'
```

**Semantics:** Luna is not stored yet, and naming her is what allows her to be created — drop mode creates only named records, so anything else the extraction produced is skipped rather than written. Alice is updated and linked to Luna. Whatever the text implied about Rex is left alone and comes back under `changes.skipped_out_of_scope`. In `reject` mode this same request would fail twice over: Luna is not stored, and Rex is not in the scope.

### What a drop-mode write skipped

A drop-mode write returns `changes.skipped_out_of_scope`, and omits the field when it skipped nothing. Each entry stands for one record and one kind of change:

```json
{
  "skipped_out_of_scope": [
    {
      "operation": "update",
      "object_type_name": "Pet",
      "identity": "",
      "fields": ["species"],
      "count": 1
    }
  ]
}
```

- `operation`: what the write would have done — `create`, `update`, `delete`, `merge`, `link` or `unlink`.
- `object_type_name`: the object type, or the relation type for a `link` / `unlink`.
- `identity`: the record's primary key, rendered as `name='Rex'` — **and empty unless your scope named that record**. The write does not tell you the identity of a record you did not name, which is the same rule its error messages follow.
- `fields`: the field names those changes would have written, empty for anything but a write of fields.
- `count`: how many records the entry stands for, so a filter that matched twenty rows outside the scope is one entry with `count: 20`.

An entry can also be a change that was itself in scope but depended on one that was not: if the write would have merged a scoped record into an unscoped one, the merge is skipped and reported, and the scoped record is left as it was.

### Permissions

A scoped write additionally requires **read** permission on the instance, on top of the write permission any write needs. A scope is not merely a restriction: the response carries the previous value of every field the write changes, resolving a scope answers whether each named record is stored, and a drop-mode report says which fields the skipped changes would have written and how many records they matched. An API key with write access alone is refused with HTTP 403.

### Error handling

Scope problems are rejected up front with HTTP 400 rather than applied partially:

```json
{
  "status": "error",
  "error_message": "No 'person' object matches the provided primary key."
}
```

The rejections to expect:

| Cause | What it means |
|---|---|
| `scope` with `structured_mutations` | Mutually exclusive. Structured mutations bypass extraction, so a scope has nothing to anchor to. |
| `scope` with an empty `objects` list | A client error, not an unscoped write. Omit `scope` entirely for that. |
| Too many objects in one scope | Each deployment caps how many records a single scope may name. |
| An unknown type, or a key matching more than one record | Scope references are resolved before the write is accepted, so a bad reference fails fast rather than silently widening. |
| A key matching no record, in `reject` mode | Every record a reject-mode scope names has to exist (see below). In `drop` mode the same reference names a record the write may create. |
| `"mode": "drop"` with the diff engine disabled | Drop mode is applied by pruning the diff engine's plan; there is nothing to prune without it. |

**A record that is not stored means different things per mode, on purpose.** A read narrows past it,
because a read's scope is the set of records a question is about and absence answers the question. A
reject-mode write refuses it: that scope is the boundary the write is confined to, and narrowing it
to nothing would silently turn a confined write into an unconfined one, free to create whatever the
extractor produced. Drop mode can take the same reference as a record to create precisely because it
closes that hole from the other side — it creates only records the scope names, so a scope that
matches nothing permits nothing rather than everything.

In `reject` mode, a violation found later — the write turning out to touch a record outside its scope — fails the write the same way: HTTP 400 on `/write`, or `write_status` reporting `failed` with the detail for `/write_async`. Those messages name object types and operations only, never the identity of a record you did not put in the scope. In `drop` mode there is nothing left to find: the out-of-scope changes are removed before the write is applied, and reported instead.

**Availability.** Scoped writes can be disabled per deployment; when off, the request fails with `"Scoped writes are not enabled on this deployment."`

---

## Extracting

Preview the objects a `write` would produce — same extraction pipeline, but nothing is stored.

**`POST /instances/{instance_id}/extract`** — Extract structured objects from text without storing them — preview what a write would produce

Parameters:
- `text` (`string`, required) — Free-form text to extract from.
- `extraction_logic` (`string`) — `"fast"` (default) or `"deep"`. Same parameters as write. Defaults to `"fast"`.
- `trace_id` (`string`) — Caller-supplied ID echoed back for log correlation.
- `session_id` (`string`) — Session ID for grouping related calls in tracing.

Returns:
- `objects_extracted` (`object`) — The extracted (but not stored) objects, as `{ objects: [...], relations: [...] }`.
- `trace_id` (`string`) — Echoed trace id for log correlation.

Example response:
```json
{ "objects_extracted": { "objects": [], "relations": [] }, "trace_id": "..." }
```

---

## Describing

Return the agent-facing tool descriptions for an instance, with its live schema in `schema_summary`.

**`GET /instances/{instance_id}/describe`** — Return the agent-facing tool descriptions for an instance, with its schema in schema_summary

Returns:
- `instance_id` (`string`) — The instance the tools operate on.
- `instance_name` (`string`) — Human-readable instance name.
- `schema_summary` (`string`) — Human-readable description of the instance's schema, suitable for including in an LLM system prompt.
- `tools` (`array`) — Tool descriptions; only includes tools the caller has permission to use.
- `purpose` (`string`) — What this memory is for — the instance description, under the name the agent-facing surfaces give it. `null` when the instance has none.
- `owner_instructions` (`string`) — The standing preference set for how agents should use this instance, rendered verbatim. Free text set by anyone holding edit permission on the instance, so label it with its provenance rather than presenting it as your own words when you put it in a prompt.
- `usage_brief` (`string`) — Generated from the instance's schema. `null` until it has been generated, and cleared again by a schema change, so treat a null here as ordinary rather than as an error.

Example response:
```json
{
  "instance_id": "...",
  "instance_name": "contacts",
  "schema_summary": "This instance tracks contacts with name, email, ...",
  "purpose": "who we have talked to and how to reach them",
  "owner_instructions": "Prefer updating an existing record over creating a near-duplicate.",
  "usage_brief": null,
  "tools": [
    {
      "name": "write",
      "description": "Extract structured data from text and persist it.",
      "when_to_use": "When you need to store new facts ...",
      "parameters": [
        { "name": "text", "type": "string", "description": "...", "required": true },
        { "name": "extraction_logic", "type": "string", "description": "...", "required": false, "enum": ["fast", "deep"], "default": "fast" }
      ],
      "http_method": "POST",
      "http_path": "/instances/.../write"
    }
  ]
}
```

---

## Clusters

Clusters are the top-level container that holds instances. List the clusters you can access, or fetch one by ID.

**`GET /clusters`** — List all clusters accessible to the authenticated user

Returns: An array of clusters, each `{ id, org_id, name, description }`.

Example request:
```bash
curl https://api.xmemory.ai/clusters \
  -H "Authorization: Bearer $XMEM_API_KEY"
```

**`GET /clusters/{cluster_id}`** — Get a single cluster by ID

Returns: A single cluster `{ id, org_id, name, description }`.

Example request:
```bash
curl https://api.xmemory.ai/clusters/$XMEM_CLUSTER_ID \
  -H "Authorization: Bearer $XMEM_API_KEY"
```

---

## Schema generation

Turn a plain-language description into typed XMD when generation is easier than authoring the
contract directly. Both paths are supported: an agent can write and validate XMD itself, or call
this operation with the memory requirements and pass the returned XMD to instance creation. Use
the [XMD guide](https://xmemory.ai/xmd/) for supported constructs and deliberate extraction tuning.

**`POST /clusters/{cluster_id}/instances/generate_schema`** — Generate an xmemory schema from a plain-language description

Parameters:
- `schema_description` (`string`, required) — Plain-language description of the data to track.
- `current_yml_schema` (`string`) — Existing schema to refine; runs the *enhance* path and returns a structured migration plan so the change can be applied safely (renames preserve data, etc.).

Returns:
- `data_schema` (`object`) — The generated schema. When `current_yml_schema` is omitted (create-from-scratch), only `data_schema` is returned.
- `migration_plan` (`object`) — Structured plan to apply the change; pass it to the dry-run and update endpoints. See [Schema evolution](#schema-evolution).
- `summary` (`string`) — Human-readable summary of the change.
- `warnings` (`array`) — Non-fatal warnings raised while generating or enhancing the schema.
- `repair_log` (`array`) — Automatic corrections applied to make the generated schema valid.

Example response:
```json
{
  "data_schema": { "...": "..." },
  "migration_plan": { "ops": [ { "op_type": "rename_field", "object_name": "person", "old_name": "mail", "new_name": "email" } ] },
  "summary": "Rename person.mail to person.email.",
  "warnings": [],
  "repair_log": []
}
```

---

## Instances

An instance is a memory store with a typed schema, living inside a cluster — create one before you write or read. These endpoints manage its whole lifecycle: create, list, fetch, update its schema or metadata, and delete.

**`POST /clusters/{cluster_id}/instances`** — Create an instance from a generated schema

Parameters:
- `name` (`string`, required) — Instance name.
- `description` (`string`) — Description.
- `instance_schema` (`object`, required) — Use `"yml": { "value": "..." }` for YAML schemas, or `"json_schema": { "value": "..." }` for JSON.
- `schema_description` (`string`) — Plain-language description stored with the instance.
- `generate_trace_id` (`string`) — Trace id linking this instance to the `generateSchema` call that produced its schema.

Returns:
- `id` (`string`) — The new instance ID. Save it — you'll pass it with every subsequent call.
- `cluster_id` (`string`) — The cluster the instance was created in.
- `name` (`string`) — Instance name.
- `description` (`string`) — Instance description, if set.
- `data_schema` (`object`) — The instance's active schema.

Example response:
```json
{
  "id": "...",
  "cluster_id": "...",
  "name": "contacts",
  "description": null,
  "data_schema": { "...": "..." }
}
```

**`GET /instances`** — List all instances accessible to the authenticated user

Returns: An array of instances (see `getInstance` for the per-item shape).

Example request:
```bash
curl https://api.xmemory.ai/instances \
  -H "Authorization: Bearer $XMEM_API_KEY"
```

**`GET /instances/{instance_id}`** — Get a single instance by ID

Returns:
- `id` (`string`) — Instance ID.
- `cluster_id` (`string`) — The cluster the instance belongs to.
- `name` (`string`) — Instance name.
- `description` (`string`) — Instance description, if set.
- `created_on` (`string`) — ISO-8601 creation timestamp.
- `created_by_email` (`string`) — Email of the account that created the instance.
- `data_schema` (`object`) — The instance's active schema.

**`GET /instances/{instance_id}/schema`** — Get the current schema of an instance

Returns:
- `data_schema` (`object`) — The instance's active schema (objects, fields, relations).

**`PUT /instances/{instance_id}/schema`** — Update the schema of an existing instance

Parameters:
- `instance_schema` (`object`, required) — The new schema. A purely additive change (new objects/fields/relations) needs only this field.
- `migration_plan` (`object`) — Required for a non-additive change (rename, remove, type change); pass the plan from `generateSchema`.
- `confirm_destructive` (`boolean`) — Set `true` for any op that drops data. Defaults to `false`.

Returns:
- `migration_id` (`string`) — Present when a migration ran (a non-additive change).
- `prior_version` (`integer`) — Schema version before this update.
- `new_version` (`integer`) — Schema version after this update.
- `migration_warnings` (`array`) — Non-fatal warnings emitted by the migration.

Errors: A non-additive change sent without a plan is rejected with `non_additive_change_requires_plan`. An op that drops data without `confirm_destructive: true` returns `destructive_confirmation_required`. See [Structured errors](#structured-errors).

**`PUT /instances/{instance_id}`** — Update instance metadata (name and description)

Parameters:
- `name` (`string`) — New instance name.
- `description` (`string`) — Updated description.
- `agent_owner_instructions` (`string`) — Standing preference for how agents should use this instance, rendered verbatim wherever it is shown. Max 2000 characters. Omit the key to leave the stored value alone; send `null` to clear it.
- `expected_owner_instructions_epoch` (`integer`) — The `agent_owner_instructions_epoch` the edit was composed from. Sent alongside an instructions edit, it causes a save that raced another writer to be refused rather than applied.
- `agent_surfaces` (`array`) — Agent surfaces this instance is expected to be used from: `claude_code`, `codex`, `claude_desktop`, `chatgpt`. Advisory — it orders the connect steps an instance is offered and grants no access. Carried here as well as on `PATCH` so a console saving the whole edit form does it in one call.
- `agent_default_binding_tier` (`string`) — Default prominence for a binding: `autoload` or `available`. Advisory.
- `agent_engagement_hints` (`array`) — Short routing phrases describing when an agent should engage this instance. At most 16, each at most 200 characters.

Returns:
- `id` (`string`) — Instance ID.
- `name` (`string`) — Updated instance name.
- `description` (`string`) — Updated description.

Errors: When `expected_owner_instructions_epoch` is sent and the stored instructions changed after it was read, the save is refused with `409` and `INVALID_INPUT` rather than discarding the other edit. See [Structured errors](#structured-errors).

**`PATCH /instances/{instance_id}`** — Update some instance metadata fields, leaving the rest alone

Parameters:
- `name` (`string`) — New instance name.
- `description` (`string`) — Updated description (send `null` to clear).
- `agent_surfaces` (`array`) — Agent surfaces this instance is expected to be used from: `claude_code`, `codex`, `claude_desktop`, `chatgpt`. Advisory — it orders the connect steps an instance is offered and grants no access.
- `agent_default_binding_tier` (`string`) — Default prominence for a binding: `autoload` or `available`. Advisory.
- `agent_engagement_hints` (`array`) — Short routing phrases describing when an agent should engage this instance. At most 16, each at most 200 characters.
- `agent_owner_instructions` (`string`) — Standing preference for how agents should use this instance. Max 2000 characters. Prefer `PUT` for this field — it is the only route that accepts `expected_owner_instructions_epoch`, so an edit sent here cannot be guarded against a concurrent one.

Returns:
- `id` (`string`) — Instance ID.
- `name` (`string`) — Instance name.
- `description` (`string`) — Instance description.

Errors: `name` may be updated but not cleared — sending `"name": null` is rejected. See [Structured errors](#structured-errors).

**`GET /instances/{instance_id}/agent_setup`** — How to connect this instance on an agent surface, most likely first

Parameters:
- `format` (`string`) — `"agent"` returns the steps to run now. `"project"` additionally returns the files a team commits once, so nobody sets the instance up by hand. Sent as a query parameter. Defaults to `"agent"`.

Returns:
- `instance_id` (`string`) — The instance these steps connect.
- `instance_name` (`string`) — The name rendered for display — one line, fence markers removed, bounded. On a create response, which carries this same payload, it can differ from the `name` beside it; do not compare or join the two.
- `install_page_url` (`string`) — The install page, for a reader who would rather follow it than the steps.
- `surfaces` (`array`) — One entry per agent surface, as `{ surface, label, steps, human_steps }`, ordered most-likely-first but never filtered — a hint about where an instance is expected to be used is not a restriction on where it may be. Each step is `{ description, command, kind }`; `kind` is `"shell"`, `"slash"` or `"url"`, and `command` is `null` where only a person can act. Read `kind` before running anything: a Claude Desktop or ChatGPT step is a connector URL to paste into the client, not a command for a shell.
- `paste_to_agent` (`string`) — One line a person can paste into an agent instead of running the steps by hand.
- `format` (`string`) — The format actually rendered. Echoed because an older node ignores an unknown query parameter and still answers `200`, so nothing else tells a caller who asked for `project` whether the instance has nothing committable or the request was never understood.
- `project` (`object`) — `null` unless `format=project` was both requested and honoured. `{ fragments, manual_steps }`; each fragment is `{ path, purpose, merge, content }`. `merge` (`"merge_json"` / `"merge_toml"`) means merge into whatever the repository already has — never overwrite, never append: applying a fragment twice must leave the file as one application would. `manual_steps` covers surfaces with no committable channel.

Details: Answers "how do I also reach this memory somewhere else" — a desktop client, another
editor, a teammate's machine. The same payload the create response carries, the
`get_setup_instructions` MCP tools serve and `xmemcli instance setup` prints. Computed
from the instance's current metadata, so editing its surface hints changes the next
response.

**Carries no credential.** The steps tell a reader to sign in through the CLI, which puts
the key on disk out of band precisely so that it stays out of a transcript.

In the SDKs as `get_setup_instructions()` / `getSetupInstructions()`, and on an instance
handle as `setup_instructions()` / `setupInstructions()` without the id.

**`DELETE /instances/{instance_id}`** — Delete an instance

Returns: A list of deleted instance IDs.

---

## Schema evolution

xmemory can change a live schema without losing data. There are two paths, both ending in an atomic migration with an audit record: a **direct migration** you drive yourself, and a **suggestion engine** that proposes improvements from real read traffic. Additive-only callers are unaffected — the endpoints above keep working as before.

**`POST /instances/{instance_id}/migrations/dry_run`** — Preview a migration without applying it

Parameters:
- `instance_schema` (`object`, required) — The proposed new schema (same body shape as the schema update).
- `migration_plan` (`object`) — Plan for a non-additive change; pass the plan from `generateSchema`.
- `confirm_destructive` (`boolean`) — Set `true` for any op that drops data. Defaults to `false`.

Returns:
- `statements` (`array`) — The planned DDL statements.
- `current_version` (`integer`) — The instance's current schema version (the migration would advance it).
- `plan_summary` (`object`) — Counts of ops by type: `{ count_by_op_type, total }`.
- `requires_metadata_sync` (`boolean`) — Whether applying would also resync derived metadata.

Example response:
```json
{
  "status": "ok",
  "instance_id": "...",
  "current_version": 4,
  "statements": ["ALTER TABLE person RENAME COLUMN mail TO email"],
  "warnings": [],
  "plan_summary": { "count_by_op_type": { "rename_field": 1 }, "total": 1 },
  "requires_metadata_sync": true
}
```

Errors: A non-additive change without a plan returns `non_additive_change_requires_plan`; a data-dropping op without confirmation returns `destructive_confirmation_required`. See [Structured errors](#structured-errors).

**`GET /instances/{instance_id}/migrations`** — List applied migrations, newest first

Parameters:
- `limit` (`integer`) — Range 1–200. Defaults to `50`.
- `before_id` (`string`) — UUID pagination cursor.
- `include_yaml` (`boolean`) — Adds before/after schema snapshots. Defaults to `false`.

Returns:
- `items` (`array`) — Migration records (see `getMigration` for the per-item shape). `source` is `direct` or `suggestion_engine`.
- `next_before_id` (`string`) — Pass as `before_id` to fetch the next (older) page; null on the last page.
- `has_more` (`boolean`) — Whether more records exist beyond this page.

Example response:
```json
{
  "status": "ok",
  "items": [
    { "id": "...", "applied_at": "...", "source": "suggestion_engine", "decided_by": "...",
      "prior_version": 3, "new_version": 4, "ops": [ ], "ops_summary": { } }
  ],
  "next_before_id": null,
  "has_more": false
}
```

**`GET /instances/{instance_id}/migrations/{migration_id}`** — Get a single migration record

Parameters:
- `include_yaml` (`boolean`) — Adds the before/after schema snapshots (`yaml_before` / `yaml_after`). Defaults to `false`.

Returns:
- `id` (`string`) — Migration record ID.
- `applied_at` (`string`) — ISO-8601 timestamp the migration was applied.
- `source` (`string`) — `direct` or `suggestion_engine`.
- `prior_version` (`integer`) — Schema version before the migration.
- `new_version` (`integer`) — Schema version after the migration.
- `ops` (`array`) — The migration operations that were applied.
- `ops_summary` (`object`) — Counts of ops by type.
- `yaml_before` (`string`) — Schema YAML before the migration (only when `include_yaml: true`).
- `yaml_after` (`string`) — Schema YAML after the migration (only when `include_yaml: true`).

### Suggestion engine — review → decide → apply

The engine surfaces a single rolling proposal per instance, derived on demand from accumulated read-gap signals. The minimum flow is three calls.

**`POST /instances/{instance_id}/suggestions/review`** — Return the consolidated proposal and a proposal_version concurrency token

Parameters:
- `session_id` (`string`) — Body may be `{}` or `{ "session_id": "..." }`.

Returns:
- `proposal` (`object`) — Holds `proposal_version` (concurrency token), `schema_version`, and `items`. An item with `apply_blocked: true` cannot be applied as proposed (for example a field another item already adds as part of a new object), and its `rationale` says why: accepting it fails the whole apply, so leave it out of a bulk accept. The flag is re-derived on every review.

Example response:
```json
{
  "status": "ok",
  "instance_id": "...",
  "proposal": {
    "proposal_version": "ab12...",
    "schema_version": 4,
    "items": [
      { "item_fingerprint": "fp1", "op": { "op_type": "add_field", "object_name": "person", "field_name": "phone", "field_type": "str" },
        "rationale": "queried but missing", "frequency": 3, "depends_on": [], "evidence_query_samples": ["what is bob's phone"],
        "apply_blocked": false }
    ]
  }
}
```

Errors: When a migration is already running, the response is `{ "status": "evolution_in_progress", "retry_after_seconds": 5 }` — back off and retry.

**`POST /instances/{instance_id}/suggestions/decide`** — Record an accept / reject / defer per item in one batch

Parameters:
- `proposal_version` (`string`, required) — The `proposal_version` from review.
- `decisions` (`array`, required) — One `{ "item_fingerprint": "...", "decision": "accept" | "reject" | "defer" }` per item.
- `session_id` (`string`) — Session ID for tracing.

Returns: The recorded decisions, advisory dependency `warnings`, and a `next_proposal_version` you can pass straight to apply.

Example response:
```json
{
  "proposal_version": "ab12...",
  "decisions": [ { "item_fingerprint": "fp1", "decision": "accept" } ]
}
```

Errors: A `proposal_version` that no longer matches the current proposal returns `stale_proposal_version`; malformed decisions return `invalid_decision_input`. See [Structured errors](#structured-errors).

**`POST /instances/{instance_id}/suggestions/apply`** — Commit accepted decisions as one migration

Parameters:
- `proposal_version` (`string`, required) — The `next_proposal_version` from decide.
- `session_id` (`string`) — Session ID for tracing.

Returns: On success, `{ "status": "ok", "migration_id": "...", "prior_version": 4, "new_version": 5, "applied_items": ["fp1"], "summary": "..." }`, or `{ "status": "nothing_to_apply" }` if no accepted items remained.

Example response:
```json
{ "status": "ok", "migration_id": "...", "prior_version": 4, "new_version": 5, "applied_items": ["fp1"], "summary": "..." }
```

Errors: A stale token returns `stale_proposal_version`; an unmet dependency returns `dependency_closure_failed`; a failed commit returns `apply_failed`. See [Structured errors](#structured-errors).

**`POST /instances/{instance_id}/pending-feedback/discard`** — Clear the pending schema-improvement backlog for this instance

Returns:
- `rows_discarded` (`integer`) — Number of pending feedback signals that were discarded.
- `instance_id` (`string`) — The instance whose backlog was cleared.

Example response:
```json
{ "status": "ok", "instance_id": "...", "rows_discarded": 12 }
```

Details: Bulk-supersedes the pending schema-improvement backlog. Idempotent — discarded signals never resurface, and calling it with nothing pending is a safe no-op.

### Structured errors

Schema-evolution endpoints return a structured error body with a 4xx/5xx status:

```json
{ "status": "error", "error_type": "stale_proposal_version", "error_message": "...", "details": { ... } }
```

`error_type` is one of: `non_additive_change_requires_plan`, `destructive_confirmation_required`, `stale_schema_version`, `migration_precondition_failed`, `migration_execution_failed`, `migration_not_found`, `stale_proposal_version`, `dependency_closure_failed`, `invalid_decision_input`, `apply_failed`, `instance_not_initialised`. The SDKs surface `error_type` as a structured error code.

### Limits

No backfill of historical data into new fields; no rollback of a committed migration (atomic abort on failure is the safety story); identity tightening (`change_object.new_primary_key`, `change_relation.new_keys`) is relax-only.

---

## Health check

**`GET /healthz`** — Returns 200 if the API is reachable (no authentication required)

Example request:
```bash
curl https://api.xmemory.ai/healthz
```

---

## Errors

All endpoints return an error response on failure with the appropriate HTTP status code (`400`, `401`, `402`, `403`, `404`, `429`, `500`, `503`).

Error bodies carry a structured `code` in `errors[0]`. **Branch on the `code`, not on the bare HTTP status** — the status alone does not say whether retrying is worthwhile.

### Quota and rate limits

| HTTP | `code` | Meaning | Retryable? |
|---|---|---|---|
| `402` | `QUOTA_EXCEEDED` | The tenant exhausted its plan's usage allowance (daily or monthly token quota). | No — only after the quota window resets. |
| `429` | `RATE_LIMITED` | A genuine velocity/rate limit was hit (too many requests in a short window). | Yes — back off and retry, honoring `Retry-After`. |

### Transient server-side failures

| HTTP | `code` | Meaning | Retryable? |
|---|---|---|---|
| `503` | `SERVICE_UNAVAILABLE` | The operation could not finish for a reason on the server's side, such as running out of its time budget ([below](#503--service_unavailable)). | Yes — retry with backoff and a cap on attempts; see below. |
| `503` | `INTERNAL_ERROR` | The operation was abandoned after repeated transient failures. | Yes — retry, but back off further; the server already retried. |

#### `503` — `SERVICE_UNAVAILABLE`

A write could not finish for a reason on the server's side. Nothing about the request is wrong, and nothing was partially applied — the write failed as a unit, and its final state is `failed`.

```json
{
  "errors": [
    {
      "code": "SERVICE_UNAVAILABLE",
      "message": "Extractor exceeded timeout: timeout=900.0s trace_id=832992_QCT"
    }
  ]
}
```

Branch on the `code`, and treat the `message` as human-facing text to log or show, not something to parse. For a write, `SERVICE_UNAVAILABLE` most often means one of these:

- **The write ran out of its time budget.**
- **The service is under high demand.**

The code doesn't say which, so handle every `SERVICE_UNAVAILABLE` alike: retry it with backoff and a cap on attempts, and when you reach the cap, stop and report the failure with its `message`. The `trace_id` a timeout's message carries is the correlation key for the operation in the [console](https://console.xmemory.ai) — log it if you are reporting the failure. Unlike `429`, a write error like these carries no `Retry-After`.

Any request, a write included, can also be answered `SERVICE_UNAVAILABLE` with a `Retry-After` header when the server is under heavy load; honor the header when present. Other operations use the same code with messages of their own.

A slow synchronous `write` streams its response to keep the connection open, so a `write` that fails after its response has started returns HTTP `200`, with the error in the body's `errors` array; the body may begin with whitespace, which JSON parsers ignore. For `write`, check `errors` rather than the HTTP status. One code there is not a failure: `WRITE_STILL_PROCESSING` means the write is still running, so poll `write_status` with `errors[0].resource_id` and don't send the write again. Polling is different: `write_status` returns HTTP `200` for a failed write too, with the failure in `items[0]`, not in the outer `errors`: a `failed` `items[0].write_status` is the signal, and `items[0].error` gives the code when present.

Note that `503` is the one status that can arrive with two different codes, which is why branching on the code matters here more than anywhere: for a write, `SERVICE_UNAVAILABLE` means this attempt at the write could not finish, while `INTERNAL_ERROR` on a `503` means the server retried the write several times without success.

#### `402` — `QUOTA_EXCEEDED`

The tenant has used up its allotted tokens for the current window. The error body adds a `details` object:

```json
{
  "errors": [
    {
      "code": "QUOTA_EXCEEDED",
      "message": "Daily token quota exceeded.",
      "details": {
        "kind": "daily_quota_exceeded",
        "retry_after_seconds": 3600
      }
    }
  ]
}
```

- `details.kind` is `"daily_quota_exceeded"` or `"monthly_quota_exceeded"`.
- `details.retry_after_seconds` is the number of seconds until the window resets, or `null` if the window is not resettable.
- When the window is resettable, the server also sends an HTTP `Retry-After: <seconds>` header.

This is **not** retryable in the short term. Surface a "usage limit reached — upgrade your plan" message to the user. Do not auto-retry; you may retry once the quota window has reset.

#### `429` — `RATE_LIMITED`

A real rate limit — the client is sending requests too fast. This **is** retryable: back off and retry, honoring the HTTP `Retry-After: <seconds>` header when present. The official SDKs parse that header into a convenience attribute — `.retry_after` (Python, `int` seconds or `None`) / `.retryAfter` (TypeScript, `number` seconds or `undefined`) — on the raised error, for both this case and a resettable `402 QUOTA_EXCEEDED`.
