# Python

> Python SDK reference.

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

The `xmemory-ai` package gives your Python code persistent, structured memory. Write free-form text, have it automatically extracted into typed objects, and query it back in natural language.

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

**API key:** To use xmemory APIs or integrations, you need an API key. Get one from the [xmemory console](https://console.xmemory.ai). Copy and securely store the key. Never share your API key publicly.

The full method, data-type, enum, and exception listing is in the [Reference](#reference) at the bottom of this page.

---

## Installation

```bash
pip install xmemory-ai
```

Requires Python 3.10+ and `pydantic>=2.0`.

---

## Quick start

This is the full flow — from schema to stored knowledge to answers — in a single script:

```python
import yaml
from xmemory import XmemoryClient, SchemaType

# Connect (reads XMEM_API_KEY from env if api_key is not passed)
client = XmemoryClient(api_key="your-api-key")

# List available clusters
clusters = client.admin.list_clusters()
cluster_id = clusters[0].id

# Describe what you want to remember
schema = client.admin.generate_schema(
    cluster_id,
    "Track contacts with name, email, company, and notes.",
)

# Create a memory instance from that schema
inst = client.admin.create_instance(
    cluster_id=cluster_id,
    name="contacts",
    schema_text=yaml.dump(schema.data_schema),
    schema_type=SchemaType.YML,
)
# inst is an InstanceAPI handle — all subsequent calls go through it.

# Write some information
inst.write("Alice Johnson works at Acme Corp. Her email is alice@acme.com.")
inst.write("Bob Lee is a designer at Globex. He joined last Monday.")

# Read it back
result = inst.read("What is Alice's email?")
print(result.reader_result)  # {"answer": "alice@acme.com"}

result = inst.read("Who joined recently?")
print(result.reader_result)  # {"answer": "Bob Lee joined last Monday."}

client.close()
```

Once you have an instance ID, skip the schema step on subsequent runs:

```python
client = XmemoryClient(api_key="your-api-key")
inst = client.instance("your-saved-instance-id")
```

---

## Configuration

| Parameter | Env var | Default | Description |
|---|---|---|---|
| `api_key` | `XMEM_API_KEY` | `None` | API key for authentication |
| `url` | `XMEM_API_URL` | `https://api.xmemory.ai` | API base URL |
| `timeout` | — | `60` | Default request timeout in seconds |
| `http_client` | — | `None` | External `httpx.Client` (you manage its lifecycle) |

All parameters are keyword-only. The API key and URL fall back to their environment variables when not passed.

### Context manager

```python
with XmemoryClient(api_key="your-api-key") as client:
    inst = client.instance("your-instance-id")
    result = inst.read("What is Alice's email?")
```

---

## Async client

`AsyncXmemoryClient` exposes the **same methods with the same signatures** as the sync
client — three things differ, and that's the whole story:

- **`await` every call** — `await inst.read(...)`, `await client.admin.create_instance(...)`.
- **`aclose()` instead of `close()`** — or just use the context manager.
- **`async with` instead of `with`.**

```python
from xmemory import AsyncXmemoryClient

async def main():
    async with AsyncXmemoryClient(api_key="your-api-key") as client:
        inst = client.instance("your-instance-id")
        result = await inst.read("What is Alice's email?")
        print(result.reader_result)
    # exiting `async with` calls aclose() for you (or: await client.aclose())

import asyncio
asyncio.run(main())
```

---

## Writing

Send free-form text — xmemory extracts structured objects according to your schema and merges them into the knowledge graph.

```python
inst = client.instance("your-instance-id")

resp = inst.write("Carol is a senior engineer at Initech. Her email is carol@initech.com.")
print(resp.changes)      # what the write created / updated / deleted
print(resp.trace_id)     # request trace id when available
print(resp.console_url)  # this operation's trace in the console
```

`WriteResult`, `ReadResult`, `AsyncWriteResult`, `WriteStatusResult`, and `ExtractResult` all carry `console_url`, a direct link to that operation's trace in the console. All of them except `WriteStatusResult` also carry `trace_id`, which is useful when you want to correlate SDK calls with API logs.

`console_url` points at the call rather than at an individual record, and it is `None` when the deployment has no console configured, so check it before rendering a link. Requires `xmemory-ai>=0.15.0`.

### Extraction logic

Control the speed/accuracy tradeoff — pass `extraction_logic=ExtractionLogic.DEEP` to override the `FAST` default:

| Value | When to use |
|---|---|
| `FAST` | High-volume, low-stakes writes (default) |
| `DEEP` | Important or complex information |

### Async writes

For latency-sensitive code, enqueue a write and return immediately:

```python
resp = inst.write_async("Dave manages the London office.")
print(resp.write_id)  # use this to poll for completion
```

Then check the status:

```python
status = inst.write_status(resp.write_id)
print(status.write_status)
# → WriteQueueStatus.QUEUED | PROCESSING | EXTRACTING | EXTRACTED | APPLYING | COMPLETED | FAILED | NOT_FOUND
```

Do not call `read` immediately after `write_async` — the data may not be committed yet. Poll with `write_status` until `COMPLETED`, or use `write` (synchronous) when you need to read right after.

Writes are applied in the order they were queued — `write`, `write_async` and structured writes alike — so a later write never lands ahead of an earlier one. Two calls in flight at once have no defined order; wait for `write_async` to return before sending a write that must apply after it. See [Write ordering](https://xmemory.ai/api/#write-ordering).

### Structured writes

When you already know exactly what to store, skip extraction: pass `structured_mutations` instead of `text` (exactly one of the two — the client raises `ValueError` otherwise). These are deterministic, LLM-free object creates, updates, and deletes or relation creates and deletes, applied in list order — later mutations may reference objects created earlier in the same batch.

```python
from xmemory import ObjectCreate, ObjectMutation, ObjectUpdate, RelationMutation

resp = inst.write(structured_mutations=[
    ObjectMutation(
        object_type="person",
        create=ObjectCreate(key={"email": "carol@initech.com"}, values={"name": "Carol"}),
    ),
    RelationMutation(
        relation_type="works_at",
        create={"endpoints": [
            {"object_name": "person", "key": {"email": "carol@initech.com"}},
            {"object_name": "company", "key": {"name": "Initech"}},
        ]},
    ),
])
print(resp.changes)  # exactly what was created / updated / deleted
```

An `ObjectMutation` carries exactly one of `create` / `update` / `delete`. `key` identifies an object by its user-defined primary-key fields, or (for update/delete) by `{"xuid": ...}`. A `None` value in object `values` **clears** that field. A `RelationMutation` creates or deletes a relation through its endpoints; this caller-addressing format does not mean object primary keys are required for relations extracted from text. XMD v1 relations do not have scalar fields:

```python
inst.write(structured_mutations=[
    ObjectMutation(
        object_type="person",
        update=ObjectUpdate(key={"email": "carol@initech.com"}, values={"role": None}),
    ),
])
```

Plain dicts in the API wire form work anywhere a model does — handy for payloads produced by an LLM:

```python
inst.write(structured_mutations=[
    {"object_mutation": {"object_type": "person", "delete": {"key": {"email": "carol@initech.com"}}}},
])
```

`write_async` accepts `structured_mutations` identically — poll with `write_status` as usual. See the [API reference](https://xmemory.ai/api/#structured-writes) for the full wire format, relation addressing (`endpoints`, `allow_bulk_delete`), and in-batch ordering rules.

---

### Scoped writes

A write normally reconciles whatever the extractor produced against every record in the instance — the right default for "here is something new", the wrong one for "here is more about *this* record". Pass a `scope` to anchor a text write to records you already know about:

```python
from xmemory import ScopeObject, WriteScope

resp = inst.write(
    "After her promotion she is a surgeon, and her desk phone is +1-555-0100.",
    scope=WriteScope(
        objects=[ScopeObject(type="Person", key={"name": "Alice Johnson"})],
    ),
)
print(resp.changes)  # Alice updated in place, not duplicated
```

The write is **confined** to the scope: free to modify or delete the scoped objects and to create new objects and relations anchored to them, but by default failing with an `XmemoryAPIError` if it would touch anything else. The confinement is checked against the resulting plan rather than asked of the extractor, so it holds regardless of what the extraction produced. Extraction itself is unchanged by a scope — the text still names the objects it means. See [Scoped writes](https://xmemory.ai/api/#scoped-writes) for the wire format and the full list of rejections.

`WriteScope` takes the same `ScopeObject`s as `ReadScope`, identified the same way. Scope everything the write should be able to change — a record left out may still be linked to, but not edited:

```python
resp = inst.write(
    "The Atlas migration finished on Tuesday and Bob Lee signed it off.",
    scope=WriteScope(objects=[
        ScopeObject(type="Project", key={"name": "Atlas"}),
        ScopeObject(type="Person", key={"name": "Bob Lee"}),
    ]),
)
```

Unlike `ReadScope` there is no `relations_scope`: relation *changes* are governed by the confinement rule rather than by a mode of their own.

`write_async` accepts the same `scope`; in the default mode a scope violation is reported by `write_status` as a failed write.

#### Skip what falls outside instead of failing

`mode="drop"` turns that failure into a report: the out-of-scope changes are skipped, the rest of the write lands, and only objects the scope names by primary key may be created — so a named object that is **not stored yet** is how you say "create or update exactly this one". Requires `xmemory-ai>=0.21.0`.

```python
resp = inst.write(
    "Alice adopted a cat named Luna. Her old dog Rex is fine.",
    scope=WriteScope(
        objects=[
            ScopeObject(type="Person", key={"name": "Alice Johnson"}),
            ScopeObject(type="Pet", key={"name": "Luna"}),  # not stored yet
        ],
        mode="drop",
    ),
)
for skip in resp.changes["skipped_out_of_scope"]:
    print(skip["operation"], skip["object_type_name"], skip["fields"], skip["count"])
```

Luna is created and linked to Alice; anything the text implied about Rex is left alone and comes back under `changes.skipped_out_of_scope`. Each entry carries the operation, the object type, the field names it would have written, how many records it stands for, and an `identity` that is empty unless your scope named that object. `write_status` carries the same summary for an async write.

#### Before you reach for it

- Scope applies to **text writes only**. Combining it with `structured_mutations` raises `ValueError` in the client, since those bypass extraction and leave a scope nothing to anchor to.
- Only objects of a type with a **user-defined 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.
- The server caps how many objects one scope may name, can have scoped writes disabled entirely, and refuses `mode="drop"` when the diff engine is off. Those are deployment rules, so they surface as an `XmemoryAPIError` rather than being second-guessed client-side.
- A scoped write additionally requires **read** permission on the instance, on top of write permission: the response carries the previous value of every field the write changed, resolving a scope answers whether each named object is stored, and a drop-mode report says what the skipped changes would have written.

## Reading

Ask questions in natural language. xmemory translates them into SQL against the knowledge graph and returns a formatted answer.

```python
resp = inst.read("Who works at Acme Corp?")
print(resp.reader_result)
print(resp.trace_id)
```

### Read modes

```python
from xmemory import ReadMode

# Plain-text answer (default)
resp = inst.read("What is Alice's email?", read_mode=ReadMode.SINGLE_ANSWER)
# → {"answer": "alice@acme.com"}

# Structured objects and relations
resp = inst.read("Show all contacts", read_mode=ReadMode.XRESPONSE)
# → {"objects": [...], "relations": [...]}

# Raw SQL result sets
resp = inst.read("List all contacts", read_mode=ReadMode.RAW_TABLES)
# → {"columns": [...], "rows": [...]}, or None when nothing matched
```

### Composite questions

A question holding several independent parts is answered in one call. The server splits it into sub-questions, answers each on its own, and runs them against one shared snapshot. Nothing has to be enabled or passed.

```python
resp = inst.read("Who owns billing, and when did the last incident close?")

resp.reader_result       # the combined answer
for part in resp.reader_results:
    print(part.sub_query, part.reader_result, part.error)
```

Each answered part's `reader_result` is in the `read_mode` you asked for, so a `RAW_TABLES` composite question gives one table per part.

`reader_results` is always a list — the wire response omits it for a single-intent question and the client normalizes that to `[]`, so an empty list means the question was not split. A part that could not be answered sets its own `error` and leaves the others intact, so check `error` per entry rather than assuming the whole call failed. A sub-question the schema provably cannot represent carries `null` rather than an empty result — that is an answer, meaning this memory cannot hold the concept, and it is worth trying a better-matching instance.

### Related types

Ask with `include_related_types=RelatedTypesMode.TYPES` and `related_types` on the result says what else the memory could answer about: the object types the read touched, the fields it did not return, and the object types a declared relation links them to, with a catalog of every named type and relation. The shape is documented on the [REST API](https://xmemory.ai/api/#related-types).

```python
from xmemory import RelatedTypesMode

resp = inst.read("Which courses require an English test?", include_related_types=RelatedTypesMode.TYPES)
for touched in resp.related_types.touched:
    print(touched.object_type, touched.fields_not_returned, [link.object_type for link in touched.related])
```

`related_types` is `None` unless the read asked for it. The API key needs `instance.get_own`, the permission the schema endpoints need; without it the read raises `XmemoryAPIError` with `.status` `403` and a message naming the permission. Available from `xmemory-ai` 0.18.0.

Add `related_types_depth=2` (up to 3) to follow the relations further: every entry under `related_types.objects` then carries its `distance` from the touched types and, below the last level, its own `related` edges; `related_types.depth` echoes the depth asked for and `omitted_objects` counts what the server's budgets kept out of the catalog ([shape](https://xmemory.ai/api/#following-relations-further)). On a one-level read these fields are absent and read as their defaults (`distance` is `None`). Available from `xmemory-ai` 0.20.0.

```python
deep = inst.read("Which courses require an English test?", include_related_types=RelatedTypesMode.TYPES, related_types_depth=2)
print({name: entry.distance for name, entry in deep.related_types.objects.items()})
```

### Scoped reads

Restrict a query to a specific set of objects. Useful for multi-tenant systems, privacy controls, and compliance requirements.

```python
from xmemory import ReadScope, ScopeObject

scope = ReadScope(
    objects=[
        ScopeObject(type="Person", key={"name": "Alice Johnson"})
    ],
    relations_scope="no_relations",
)
resp = inst.read("What is their email?", scope=scope)
print(resp.reader_result)
```

Each `ScopeObject` names one object by its `type` — the PascalCase class name or the snake_case table name — plus its user-defined primary `key`, with one entry per primary-key field. Only objects of a type that declares a primary key can be scoped.

#### Multiple objects with relations

```python
scope = ReadScope(
    objects=[
        ScopeObject(type="Person", key={"email": "alice@example.com"}),
        ScopeObject(type="Person", key={"email": "bob@example.com"}),
    ],
    relations_scope="all_relations",
)
resp = inst.read("Which projects do these people work on together?", scope=scope)
```

#### Scope options

`relations_scope` takes one of two string values:

- `"no_relations"` (default): Only the specified objects.
- `"all_relations"`: Also expose the relations among the specified objects.

#### Objects that are not there

A `ScopeObject` naming an object the instance does not hold does **not** raise. It drops out of
the scope, and the read answers from whatever is left — an empty result when nothing matched, or
the objects that do exist when only some did. So you can scope to an object before you know
whether it has been written yet, which is the opposite of a scoped *write*, where every named
object must exist.

The trade-off: an aggregate is over the objects that were found, not the ones you listed. If it
matters that every entry resolved, read the objects back rather than trusting a count over them.
See [Scoped reads](https://xmemory.ai/api/#scoped-reads) for the full rules, including the malformed scopes that do
still raise.

#### Connecting an instance elsewhere

`admin.get_setup_instructions(instance_id)` and `inst.setup_instructions()` both return an
`AgentSetupResult`: how to reach the same memory from another agent surface, ordered
most-likely-first. On either handle, because the MCP instance connection serves the same tool.

```python
setup = inst.setup_instructions()
for surface in setup.surfaces:
    print(surface.label)
    for step in surface.steps:
        print(" ", step.description, step.command or "")
```

The default `SetupFormat.AGENT` answers *what do I run right now, here*. `SetupFormat.PROJECT`
also returns the files a team commits once, so nobody sets the instance up by hand:

```python
from xmemory import SetupFormat

setup = inst.setup_instructions(format=SetupFormat.PROJECT)
if setup.format == SetupFormat.PROJECT and setup.project:
    for fragment in setup.project.fragments:
        print(fragment.path, fragment.merge)   # a merge, never a file to overwrite
```

**Check `setup.format` rather than assuming.** A server older than that parameter ignores it and
still answers 200, so asking for `PROJECT` is not the same as receiving it.

Nothing returned carries a credential: the steps tell a reader to sign in themselves, out of band,
so an instance id stays an identifier rather than a key.

Advisory values — `step.kind`, `fragment.merge`, `format` — arrive as enum members when this
release knows them and as plain strings when it does not, so a value added to the server later
cannot make the whole result unparseable. A `step.kind` you do not recognise is not something to
execute.

## Error handling

```python
from xmemory import XmemoryAPIError

try:
    resp = inst.read("...", scope=scope)
except XmemoryAPIError as e:
    if e.status == 400:
        print(f"Invalid scope: {e.message}")
    else:
        raise
```

---

## Extracting (without writing)

Preview what xmemory would extract from a piece of text, without storing anything:

```python
resp = inst.extract("Dave manages the London office.")
print(resp.objects_extracted)
print(resp.trace_id)
```

Accepts the same `extraction_logic` parameter as `write`.

---

## Describing (agent tool discovery)

The `describe()` method returns the agent-facing tool descriptions for an instance, with its schema in `schema_summary`. Use it to tell an LLM what tools are available and how to call them.

```python
desc = inst.describe()

# Plain text — inject into a system prompt
print(desc.as_text())

# Anthropic tool-use format
tools = desc.as_anthropic_tools()

# OpenAI function-calling format
tools = desc.as_openai_tools()
```

Results are cached locally for 5 minutes. To force a refresh (e.g. after updating the schema):

```python
inst.clear_describe_cache()
desc = inst.describe()
```

`as_text()` shows tools as method signatures by default. Pass `include_http=True` to also show HTTP method and path for raw REST callers.

`describe()` also carries what the instance is *for*:

```python
desc.purpose             # what the memory is for (the instance description)
desc.owner_instructions  # the standing preference set for it, verbatim
desc.usage_brief         # generated from the schema; None until generated
```

`as_text()` includes the first two — the purpose under the instance line, the
standing preference above the schema summary. `usage_brief` is left out of it,
since it restates the schema summary already there; read the attribute if you
want it.

Both are free text set by anyone holding edit permission on the instance, so
`as_text()` labels each with where it came from rather than presenting it as the
library's own words. Those labels state provenance; they are not a security
boundary. If you inject this into a system prompt you are still handling text
you do not control.

---

## Cluster and instance management

These methods live on `client.admin` and cover the control plane: discovering clusters, generating schemas, creating instances, and evolving them safely.

### Clusters

```python
clusters = client.admin.list_clusters()
for c in clusters:
    print(f"{c.id}: {c.name}")
```

### Schema generation

Describe what you want to track in plain language when generation is more convenient than writing
XMD directly. Agents may use either path:

```python
schema = client.admin.generate_schema(
    cluster_id,
    "Track user preferences, open tasks with priorities, and conversation history.",
)
print(schema.data_schema)
```

Object names use **CamelCase** (`UserPreferences`, `OpenTask`). The generation endpoint handles naming conventions automatically. The returned document is [XMD](https://xmemory.ai/xmd/), whose descriptions fine-tune extraction and permit grounded derivation rather than merely defining domain terminology. To evolve an existing schema (rename / remove / type change) rather than create one, retrieve the current XMD and use `enhance_schema` — it returns a structured migration plan; see [Schema evolution](#schema-evolution).

### Instances

Create an instance from a generated schema:

```python
import yaml
from xmemory import SchemaType

inst = client.admin.create_instance(
    cluster_id=cluster_id,
    name="my-memory",
    schema_text=yaml.dump(schema.data_schema),
    schema_type=SchemaType.YML,
    description="Optional description",
)
# inst is a bound InstanceAPI — use inst.write(), inst.read(), etc.
```

For a purely additive change (new objects/fields/relations), update the schema directly — existing data is preserved:

```python
new_schema = client.admin.generate_schema(
    cluster_id,
    "Add an assignee field to tasks.",
    current_yml_schema=current_schema,
)
client.admin.update_instance_schema(
    instance_id, yaml.dump(new_schema.data_schema), SchemaType.YML
)
```

List and delete instances:

```python
instances = client.admin.list_instances()
info = client.admin.get_instance(instance_id)

client.admin.delete_instance(instance_id)
```

### Agent-facing instance metadata

An instance can carry metadata that shapes how agents connect to it and what they
do with it. Use `patch_instance_metadata` to set the advisory hints: every
argument is independent — **omitting one leaves the stored value untouched**,
and passing `None` clears it.

```python
from xmemory import AgentSurface, BindingTier

client.admin.patch_instance_metadata(
    instance_id,
    # Advisory hints — they seed what a connect flow proposes, and grant nothing.
    agent_surfaces=[AgentSurface.CLAUDE_CODE, AgentSurface.CODEX],
    agent_default_binding_tier=BindingTier.AUTOLOAD,
    agent_engagement_hints=["a convention is learned or corrected"],
)
```

Concurrent edits to these three are last-writer-wins by design: they only seed
what a connect flow proposes, so the loser of a race re-applies a suggestion.
`agent_owner_instructions` is not like that.

Read it back from `InstanceInfo`. The hints come back as plain strings, so a
value your server knows and your library version does not is returned rather
than rejected:

```python
info = client.admin.get_instance(instance_id)
info.agent_owner_instructions
info.agent_surfaces                 # e.g. ["claude_code", "codex"]
info.agent_default_binding_tier     # e.g. "autoload"
```

Set `agent_owner_instructions` through `update_instance_metadata`, not
`patch_instance_metadata`. It is rendered to agents verbatim and a second
writer edits it from the same screen, so a silently lost edit is a rule that
stops being enforced — and `update_instance_metadata` is the only call that
carries `expected_owner_instructions_epoch`. Pass the epoch you read the value
at and the losing save is refused instead of applied:

```python
info = client.admin.get_instance(instance_id)
client.admin.update_instance_metadata(
    instance_id, info.name, info.description,
    agent_owner_instructions=(info.agent_owner_instructions or "") + "\nAlso: never paraphrase a rule.",
    expected_owner_instructions_epoch=info.agent_owner_instructions_epoch,
)
```

### Schema evolution

Non-additive changes (rename / remove / type change) preserve data via a structured **migration plan**. Use `enhance_schema` to get the plan, `dry_run_migration` to preview the DDL, then `update_instance_schema` to apply it. `confirm_destructive=True` is required for ops that drop data.

```python
current = client.admin.get_instance_schema(instance_id).data_schema

# 1. Enhance — new schema + an executor-ready migration plan.
enhanced = client.admin.enhance_schema(
    cluster_id,
    "Rename Person.mail to Person.email.",
    yaml.dump(current),
)
print(enhanced.summary)
for op in enhanced.migration_plan.ops:
    print(op)

new_yaml = yaml.dump(enhanced.data_schema)

# 2. Dry-run — preview the DDL, apply nothing.
preview = client.admin.dry_run_migration(
    instance_id, new_yaml, SchemaType.YML, migration_plan=enhanced.migration_plan,
)
print(preview.statements)

# 3. Update — apply. A rename is non-destructive, so confirm_destructive stays False.
info = client.admin.update_instance_schema(
    instance_id, new_yaml, SchemaType.YML,
    migration_plan=enhanced.migration_plan, confirm_destructive=False,
)
print(info.migration_id, info.prior_version, "->", info.new_version)
```

Review applied migrations:

```python
page = client.admin.list_migrations(instance_id, limit=20)
for r in page.items:
    print(r.id, r.source, r.prior_version, "->", r.new_version)

detail = client.admin.get_migration(instance_id, page.items[0].id, include_yaml=True)
print(detail.yaml_before, detail.yaml_after)
```

Migration ops are exported as typed models (`MigrationPlan`, `AddField`,
`RenameField`, `RemoveObject`, …). `ProposalItem.op` and `MigrationRecord.ops`
stay as raw dicts for forward compatibility — use `parse_migration_op(...)` to
validate them.

### Suggestion engine (review → decide → apply)

xmemory watches read traffic and, on demand, surfaces a single rolling proposal
of schema improvements. The flow is three calls — never wrap them; the
checkpoints are the point.

```python
from xmemory import DecisionInput

inst = client.instance(instance_id)

# 1. Review — proposal + optimistic-concurrency token.
review = inst.review_suggestions()
if review.status == "evolution_in_progress":
    print(f"Migration in flight; retry in {review.retry_after_seconds}s")
else:
    proposal = review.proposal
    for item in proposal.items:
        print(item.item_fingerprint, item.rationale, item.op)

    # 2. Decide — accept / reject / defer per item, in bulk. Leave out items
    #    flagged apply_blocked: accepting one fails the whole apply.
    decisions = [
        DecisionInput(item_fingerprint=i.item_fingerprint, decision="accept")
        for i in proposal.items if not i.apply_blocked
    ]
    if decisions:
        decided = inst.decide_suggestions(proposal.proposal_version, decisions)

        # 3. Apply — commit accepted decisions as one migration.
        applied = inst.apply_pending_decisions(decided.next_proposal_version)
        print(applied.status, applied.summary)
```

An item with `apply_blocked=True` cannot be applied as proposed — for example a field that another item in the same proposal already adds as part of a new object — and its `rationale` says why. Accepting it fails the whole apply, so a bulk accept leaves it out; the remaining items apply together. When every item is flagged there is nothing to accept: skip the decide call, which refuses an empty batch with `invalid_decision_input`. Requires `xmemory-ai>=0.22.0`.

---

## Error handling

All errors raise `XmemoryAPIError`. The exception carries an optional `.status` (HTTP status code), `.code` (structured error code, when the server returned one), `.details`, and `.retry_after` (int seconds parsed from the `Retry-After` header, `>= 0`, or `None`).

```python
from xmemory import XmemoryAPIError, XmemoryHealthCheckError

# Check connectivity
try:
    client.check_health()
except XmemoryHealthCheckError as e:
    print(f"API unreachable: {e}")

# Handle operation errors
try:
    inst.write("...")
except XmemoryAPIError as e:
    print(f"Error (HTTP {e.status}): {e}")
```

`XmemoryHealthCheckError` is a subclass of `XmemoryAPIError`, so catching `XmemoryAPIError` covers both.

The schema-evolution methods set `.code` to a structured error type you can match on — e.g. `stale_proposal_version`, `destructive_confirmation_required`, `non_additive_change_requires_plan`:

```python
try:
    inst.apply_pending_decisions(token)
except XmemoryAPIError as e:
    if e.code == "stale_proposal_version":
        review = inst.review_suggestions()  # re-review and retry
```

The same `.code` branching separates a spent allowance from a genuine rate limit — match on `.code`, not on the status:

```python
try:
    inst.write("...")
except XmemoryAPIError as e:
    if e.code == "QUOTA_EXCEEDED":
        # Plan/usage allowance exhausted; e.details["kind"] is
        # "daily_quota_exceeded" or "monthly_quota_exceeded".
        # Not retryable now — surface "limit reached / upgrade your plan".
        # You may retry after e.details["retry_after_seconds"] (if not None).
        ...
    elif e.code == "RATE_LIMITED":
        # Genuine rate limit — back off and retry, honoring Retry-After.
        ...
    else:
        raise
```

For both `RATE_LIMITED` (429) and a resettable `QUOTA_EXCEEDED` (402), use `e.retry_after` — the seconds parsed from the `Retry-After` header (an `int >= 0`, or `None` when the header was absent or in HTTP-date form) — as your backoff delay.

---

## Reference

The complete method, data-type, enum, and exception listing. Methods are grouped by where they live; expand a row for its parameters, returns, and details.

### Client

Constructors and client-level handles.

**`XmemoryClient(url=None, *, timeout=60, api_key=None, http_client=None)`** — Create a sync client

Parameters:
- `url` (`str`) — API base URL; falls back to `XMEM_API_URL`. The only positional arg. Defaults to `None`.
- `timeout` (`float`) — Default request timeout in seconds. Defaults to `60`.
- `api_key` (`str`) — API key for authentication; falls back to `XMEM_API_KEY`. Defaults to `None`.
- `http_client` (`httpx.Client`) — External HTTP client (you manage its lifecycle). Defaults to `None`.

**`AsyncXmemoryClient(url=None, *, timeout=60, api_key=None, http_client=None)`** — Create an async client (same signature; methods are awaitable)

Parameters:
- `url` (`str`) — API base URL; falls back to `XMEM_API_URL`. The only positional arg. Defaults to `None`.
- `timeout` (`float`) — Default request timeout in seconds. Defaults to `60`.
- `api_key` (`str`) — API key for authentication; falls back to `XMEM_API_KEY`. Defaults to `None`.
- `http_client` (`httpx.AsyncClient`) — External HTTP client (you manage its lifecycle). Defaults to `None`.

**`client.admin`** — Control-plane accessor — clusters, schema generation, instances, schema evolution

Returns: `client.admin` (`AsyncAdminAPI` on the async client) — the receiver for the admin / control-plane methods (clusters, schema generation, instances, schema evolution).

**`client.instance(instance_id)`** — Get a handle for data operations on an instance

Parameters:
- `instance_id` (`str`, required) — The instance ID to operate on.

Returns: A bound `InstanceAPI` handle (`AsyncInstanceAPI` on the async client) — call `write` / `read` / `extract` / … on it.

**`client.check_health()`** — Raise XmemoryHealthCheckError if the API is unreachable

Returns: `None` on success; raises `XmemoryHealthCheckError` otherwise.

**`client.close()`** — Close the underlying HTTP client (no-op if you supplied your own)

Details: **Async client:** use `await client.aclose()` instead — or prefer the context manager (`with` / `async with`).

**`await client.aclose()`** — Async counterpart of close() (AsyncXmemoryClient)

### Admin methods

Control-plane methods on `client.admin` — clusters, schema generation, instances, and schema evolution.

**`list_clusters(*, ids=None, timeout=None)`** — List clusters

Parameters:
- `ids` (`list[str]`) — Filter to specific cluster IDs.
- `timeout` (`float`) — Per-call timeout in seconds.

Returns: `list[ClusterInfo]`

**`get_cluster(cluster_id, *, timeout=None)`** — Get a cluster by ID

Parameters:
- `cluster_id` (`str`, required) — The cluster ID to fetch.
- `timeout` (`float`) — Per-call timeout in seconds.

Returns: `ClusterInfo`

**`generate_schema(cluster_id, schema_description, *, current_yml_schema=None, timeout=None)`** — Generate a schema from a plain-language description

Parameters:
- `cluster_id` (`str`, required) — The cluster to generate the schema for.
- `schema_description` (`str`, required) — Plain-language description of what to track.
- `current_yml_schema` (`str`) — Existing schema to extend additively.
- `timeout` (`float`) — Per-call timeout in seconds.

Returns: `GenerateSchemaResult`

**`enhance_schema(cluster_id, schema_description, current_yml_schema, *, timeout=None)`** — Evolve a schema → new schema + migration plan

Parameters:
- `cluster_id` (`str`, required) — The cluster the schema belongs to.
- `schema_description` (`str`, required) — Plain-language description of the change.
- `current_yml_schema` (`str`, required) — The current schema to evolve.
- `timeout` (`float`) — Per-call timeout in seconds.

Returns: Pass the returned `migration_plan` to `dry_run_migration` / `update_instance_schema`.

**`create_instance(cluster_id, name, schema_text, schema_type, *, description=None, schema_description=None, timeout=None)`** — Create a new instance; returns a bound handle

Parameters:
- `cluster_id` (`str`, required) — The cluster to create the instance in.
- `name` (`str`, required) — Name for the instance.
- `schema_text` (`str`, required) — The schema document text.
- `schema_type` (`SchemaType`, required) — Format of the schema (`YML` or `JSON`).
- `description` (`str`) — Description.
- `schema_description` (`str`) — Plain-language schema description.
- `timeout` (`float`) — Per-call timeout in seconds.

Returns: A bound `InstanceAPI` handle for the new instance — call `inst.id` to get its ID.

**`list_instances(*, ids=None, timeout=None)`** — List instances

Parameters:
- `ids` (`list[str]`) — Filter to specific instance IDs.
- `timeout` (`float`) — Per-call timeout in seconds.

Returns: `list[InstanceInfo]`

**`get_instance(instance_id, *, timeout=None)`** — Get an instance by ID

Parameters:
- `instance_id` (`str`, required) — The instance ID to fetch.
- `timeout` (`float`) — Per-call timeout in seconds.

Returns: `InstanceInfo`

**`get_instance_schema(instance_id, *, timeout=None)`** — Get an instance's schema

Parameters:
- `instance_id` (`str`, required) — The instance whose schema to fetch.
- `timeout` (`float`) — Per-call timeout in seconds.

Returns: `InstanceSchemaInfo`

**`update_instance_schema(instance_id, schema_text, schema_type, *, migration_plan=None, confirm_destructive=False, timeout=None)`** — Update a schema; pass migration_plan for non-additive changes

Parameters:
- `instance_id` (`str`, required) — The instance to update.
- `schema_text` (`str`, required) — The new schema document text.
- `schema_type` (`SchemaType`, required) — Format of the schema (`YML` or `JSON`).
- `migration_plan` (`MigrationPlan | dict`) — Plan for non-additive changes (from `enhance_schema`).
- `confirm_destructive` (`bool`) — Required for ops that drop data. Defaults to `False`.
- `timeout` (`float`) — Per-call timeout in seconds.

Returns: The `migration_id` / `prior_version` / `new_version` / `migration_warnings` fields are populated when a migration ran.

Errors: Raises `XmemoryAPIError` with `.code` `non_additive_change_requires_plan` (no plan) or `destructive_confirmation_required` (drops data without `confirm_destructive=True`).

**`update_instance_metadata(instance_id, name, description, *, agent_owner_instructions=UNSET, expected_owner_instructions_epoch=UNSET, timeout=None)`** — Update instance name and description

Parameters:
- `instance_id` (`str`, required) — The instance to update.
- `name` (`str`, required) — New name for the instance.
- `description` (`str | None`, required) — New description (pass `None` to clear).
- `agent_owner_instructions` (`str | None`) — Standing preference for how agents should use this instance. Omit to leave it as it is; pass `None` to clear it. Max 2000 characters. Defaults to `UNSET`.
- `expected_owner_instructions_epoch` (`int`) — The `agent_owner_instructions_epoch` your edit was composed from. Defaults to `UNSET`.
- `timeout` (`float`) — Per-call timeout in seconds.

Returns: Omitting an argument sends no such key at all, so a rename never disturbs the standing preference.

Errors: When `expected_owner_instructions_epoch` is sent and the stored instructions changed after you read them, the save is refused rather than applied: `XmemoryAPIError` with `.status` `409` and `.code` `INVALID_INPUT`. Re-read the instance, reapply your change, and save again.

**`patch_instance_metadata(instance_id, *, name=UNSET, description=UNSET, agent_surfaces=UNSET, agent_default_binding_tier=UNSET, agent_engagement_hints=UNSET, agent_owner_instructions=UNSET, timeout=None)`** — Change some metadata fields, leaving the rest alone

Parameters:
- `instance_id` (`str`, required) — The instance to update.
- `name` (`str`) — New name for the instance. Defaults to `UNSET`.
- `description` (`str | None`) — New description (pass `None` to clear). Defaults to `UNSET`.
- `agent_surfaces` (`Sequence[str] | None`) — Agent surfaces this instance is expected to be used from — see `AgentSurface`. Advisory: it orders the connect steps and grants nothing. Defaults to `UNSET`.
- `agent_default_binding_tier` (`str | None`) — Default prominence for a binding — see `BindingTier`. Advisory. Defaults to `UNSET`.
- `agent_engagement_hints` (`Sequence[str] | None`) — Short routing phrases, e.g. `"a convention is learned or corrected"`. At most 16, each at most 200 characters. Defaults to `UNSET`.
- `agent_owner_instructions` (`str | None`) — Standing preference for how agents should use this instance. Max 2000 characters. Prefer `update_instance_metadata` for this field — it is the only call that accepts `expected_owner_instructions_epoch`, so an edit sent here cannot be guarded against a concurrent one. Defaults to `UNSET`.
- `timeout` (`float`) — Per-call timeout in seconds.

Returns: Every argument is independent: omit one and the stored value is untouched, pass `None` to clear it. This is the only call that accepts the three `agent_*` hints; `update_instance_metadata` is the only one that accepts the epoch guard.

**`get_setup_instructions(instance_id, *, format=SetupFormat.AGENT, timeout=None)`** — How to connect this instance on an agent surface, most likely first

Parameters:
- `instance_id` (`str`, required) — The instance to describe connect steps for.
- `format` (`SetupFormat | str`) — `AGENT` returns the steps to run now. `PROJECT` additionally returns the files a team commits once, so nobody sets the instance up by hand. Defaults to `SetupFormat.AGENT`.

Returns: `AgentSetupResult`

Details: Answers "how do I also reach this memory somewhere else" — a desktop client, another
editor, a teammate's machine — rather than how to reach it from here. The same payload
the `get_setup_instructions` MCP tool serves and `xmemcli instance setup` prints.

Also on an instance handle as `instance.setup_instructions()`, without the id.

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 themselves, out of band, so
nothing returned is a secret and an instance id stays an identifier rather than a key.

Read `result.format` rather than assuming: a server older than the `format` parameter
ignores it and still answers 200, so asking for `PROJECT` is not the same as receiving it.

Advisory values tolerate a newer server. `step.kind`, `fragment.merge` and `format` come
back as enum members when this release knows them and as plain strings when it does not,
so a value added later cannot make the whole result unparseable. A `step.kind` you do not
recognise is not something to execute.

**`delete_instance(instance_id, *, timeout=None)`** — Delete an instance

Parameters:
- `instance_id` (`str`, required) — The instance ID to delete.
- `timeout` (`float`) — Per-call timeout in seconds.

Returns: The deleted instance IDs.

**`dry_run_migration(instance_id, schema_text, schema_type, *, migration_plan=None, confirm_destructive=False, timeout=None)`** — Preview a migration's DDL

Parameters:
- `instance_id` (`str`, required) — The instance to preview against.
- `schema_text` (`str`, required) — The new schema document text.
- `schema_type` (`SchemaType`, required) — Format of the schema (`YML` or `JSON`).
- `migration_plan` (`MigrationPlan | dict`) — Plan for non-additive changes.
- `confirm_destructive` (`bool`) — Required for ops that drop data. Defaults to `False`.
- `timeout` (`float`) — Per-call timeout in seconds.

Returns: `statements` are the planned DDL; nothing is applied.

**`list_migrations(instance_id, *, limit=50, before_id=None, include_yaml=False, timeout=None)`** — List applied migrations (newest first)

Parameters:
- `instance_id` (`str`, required) — The instance to list migrations for.
- `limit` (`int`) — Maximum number of records to return. Defaults to `50`.
- `before_id` (`str`) — Page cursor: pass the previous page's `next_before_id`.
- `include_yaml` (`bool`) — Include the before/after YAML in each record. Defaults to `False`.
- `timeout` (`float`) — Per-call timeout in seconds.

Returns: Page with `next_before_id` — pass it as `before_id` to fetch the next page.

**`get_migration(instance_id, migration_id, *, include_yaml=False, timeout=None)`** — Get a single migration record

Parameters:
- `instance_id` (`str`, required) — The instance the migration belongs to.
- `migration_id` (`str`, required) — The migration record ID.
- `include_yaml` (`bool`) — Include the before/after YAML snapshots. Defaults to `False`.
- `timeout` (`float`) — Per-call timeout in seconds.

Returns: `yaml_before` / `yaml_after` are populated only when `include_yaml=True`.

### Instance methods

Data-plane methods on a bound instance (`client.instance(id)`) — writing, reading, extracting, describing, and the suggestion engine.

**`write(text="", *, structured_mutations=None, extraction_logic=ExtractionLogic.FAST, diff_engine=None, scope=None, timeout=None)`** — Persist data — extract objects from text, or apply structured mutations

Parameters:
- `text` (`str`) — Free-form text to extract from. Exactly one of `text` / `structured_mutations` must be provided (enforced with `ValueError`).
- `structured_mutations` (`list`) — Ordered list of `ObjectMutation` / `RelationMutation` models (or equivalent wire-form dicts): deterministic object create/update/delete and relation create/delete mutations — see [Structured writes](https://xmemory.ai/python/#structured-writes). Mutually exclusive with `text`.
- `extraction_logic` (`ExtractionLogic`) — Speed/accuracy tradeoff (`FAST` default, or `DEEP`). Text writes only. Defaults to `ExtractionLogic.FAST`.
- `diff_engine` (`bool`) — Override the server's diff-engine setting for this write. Text writes only.
- `scope` (`WriteScope | None`) — Anchor a text write to concrete existing objects; the write is then confined to them. With the default `mode="reject"` touching any other existing object fails the write, and with `mode="drop"` those changes are skipped and reported under `changes.skipped_out_of_scope` instead. Text writes only (combining it with `structured_mutations` raises `ValueError`), and it additionally requires read permission on the instance. See [Scoped writes](https://xmemory.ai/python/#scoped-writes).
- `timeout` (`float`) — Per-call timeout in seconds.

Returns: `WriteResult`

**`write_async(text="", *, structured_mutations=None, extraction_logic=ExtractionLogic.FAST, diff_engine=None, scope=None, timeout=None)`** — Enqueue a write; returns write_id immediately

Parameters:
- `text` (`str`) — Free-form text to extract from. Exactly one of `text` / `structured_mutations` must be provided.
- `structured_mutations` (`list`) — Same structured-mutation input as `write`. Mutually exclusive with `text`.
- `extraction_logic` (`ExtractionLogic`) — Speed/accuracy tradeoff (`FAST` default, or `DEEP`). Text writes only. Defaults to `ExtractionLogic.FAST`.
- `diff_engine` (`bool`) — Override the server's diff-engine setting for this write. Text writes only.
- `scope` (`WriteScope | None`) — Anchor a text write to concrete existing objects; the write is then confined to them. With the default `mode="reject"` touching any other existing object fails the write, and with `mode="drop"` those changes are skipped and reported under `changes.skipped_out_of_scope` instead. Text writes only (combining it with `structured_mutations` raises `ValueError`), and it additionally requires read permission on the instance. See [Scoped writes](https://xmemory.ai/python/#scoped-writes).
- `timeout` (`float`) — Per-call timeout in seconds.

Returns: Poll for completion with `write_status` using the returned `write_id`.

**`write_status(write_id, *, timeout=None)`** — Poll the status of an async write

Parameters:
- `write_id` (`str`, required) — The write ID returned by `write_async`.
- `timeout` (`float`) — Per-call timeout in seconds.

Returns: `WriteStatusResult`

**`read(query, *, read_mode=ReadMode.SINGLE_ANSWER, scope=None, include_related_types=None, related_types_depth=None, read_id=None, timeout=None)`** — Query the instance in natural language

Parameters:
- `query` (`str`, required) — Natural-language question.
- `read_mode` (`ReadMode`) — Result shape (`SINGLE_ANSWER` default, `XRESPONSE`, or `RAW_TABLES`). Defaults to `ReadMode.SINGLE_ANSWER`.
- `scope` (`ReadScope | None`) — Restrict the read to concrete objects, and optionally to the relations among them. See [Scoped reads](https://xmemory.ai/python/#scoped-reads).
- `include_related_types` (`RelatedTypesMode | None`) — `RelatedTypesMode.TYPES` asks for `related_types` on the result. Sent only when set. Needs `instance.get_own` on the API key. See [Related types](https://xmemory.ai/python/#related-types).
- `related_types_depth` (`int | None`) — With `RelatedTypesMode.TYPES`, how many relation levels `related_types.objects` follows out from the touched types, 1 to 3. Sent only when set. See [Related types](https://xmemory.ai/python/#related-types).
- `read_id` (`str`) — Correlation ID for the read.
- `timeout` (`float`) — Per-call timeout in seconds.

Returns: `reader_result` is shaped by `read_mode` (e.g. `{"answer": ...}`).

A question holding several independent parts is split server-side into sub-questions, and `reader_results` then holds one entry per sub-question — each `{ sub_query, reader_result, error }`, with every `reader_result` in the requested mode. `reader_result` stays the combined value. Unlike the REST response, which omits the key entirely for a single-intent question, the client always gives you a list here: it is empty unless the question was split. A sub-question that could not be answered sets its own `error` and leaves the others intact. A sub-question the schema provably cannot represent carries `null` rather than an empty result — that is an answer, meaning this memory cannot hold the concept, and it is worth trying a better-matching instance.

`related_types` is a `RelatedTypes` when the read asked for it with `include_related_types`, and `None` otherwise. See [Related types](https://xmemory.ai/python/#related-types).

**`extract(text, *, extraction_logic=ExtractionLogic.FAST, timeout=None)`** — Extract objects without writing them

Parameters:
- `text` (`str`, required) — Free-form text to extract from.
- `extraction_logic` (`ExtractionLogic`) — Speed/accuracy tradeoff (`FAST` default, or `DEEP`). Defaults to `ExtractionLogic.FAST`.
- `timeout` (`float`) — Per-call timeout in seconds.

Returns: `ExtractResult`

**`describe(*, timeout=None)`** — Get agent-facing tool descriptions (cached 5 min)

Parameters:
- `timeout` (`float`) — Per-call timeout in seconds.

Returns: `DescribeResult`

**`clear_describe_cache()`** — Force next describe() to fetch fresh data

Returns: `None`

**`get_schema(*, timeout=None)`** — Get this instance's schema

Parameters:
- `timeout` (`float`) — Per-call timeout in seconds.

Returns: `InstanceSchemaInfo`

**`setup_instructions(*, format=SetupFormat.AGENT, timeout=None)`** — How to connect *this* instance somewhere else, most likely surface first

Parameters:
- `format` (`SetupFormat | str`) — `AGENT` returns the steps to run now; `PROJECT` also returns the files a team commits once. Defaults to `SetupFormat.AGENT`.

Returns: `AgentSetupResult`

Details: The instance-scoped counterpart of `admin.get_setup_instructions()`, which takes an id.
On both because the MCP instance connection serves the same tool, so a caller holding an
instance handle need not reach through `admin` for it.

Not cached, unlike `describe()`: computed from the instance's current metadata, so an
owner who edits a surface hint expects the next call to show it.

**`review_suggestions(*, session_id=None, timeout=None)`** — Get the rolling schema-improvement proposal

Parameters:
- `session_id` (`str`) — Session correlation ID.
- `timeout` (`float`) — Per-call timeout in seconds.

Returns: On `status` `evolution_in_progress`, back off for `retry_after_seconds` and retry — it's a status, not an error.

**`decide_suggestions(proposal_version, decisions, *, session_id=None, timeout=None)`** — Record accept/reject/defer decisions in bulk

Parameters:
- `proposal_version` (`str`, required) — Optimistic-concurrency token from the review.
- `decisions` (`list[DecisionInput]`, required) — Per-item decisions — `DecisionInput(item_fingerprint, decision, edits=None)`.
- `session_id` (`str`) — Session correlation ID.
- `timeout` (`float`) — Per-call timeout in seconds.

Returns: Pass `next_proposal_version` straight to `apply_pending_decisions` without re-reviewing.

Errors: Raises `XmemoryAPIError` with `.code` `stale_proposal_version` (token out of date) or `invalid_decision_input`.

**`apply_pending_decisions(proposal_version, *, session_id=None, timeout=None)`** — Apply accepted decisions as one migration

Parameters:
- `proposal_version` (`str`, required) — The `next_proposal_version` from the decision step.
- `session_id` (`str`) — Session correlation ID.
- `timeout` (`float`) — Per-call timeout in seconds.

Returns: `status` `nothing_to_apply` means no accepted items were left to apply.

Errors: Raises `XmemoryAPIError` with `.code` `stale_proposal_version`, `dependency_closure_failed`, or `apply_failed`.

### Types

**Data types**

**`ClusterInfo`** — A cluster.
- `id` (`str`) — Cluster ID.
- `org_id` (`str`) — Owning organization ID.
- `name` (`str`) — Cluster name.
- `description` (`str | None`) — Cluster description.

**`InstanceInfo`** — An instance's metadata.
- `id` (`str`) — Instance ID.
- `cluster_id` (`str`) — The cluster this instance belongs to.
- `name` (`str`) — Instance name.
- `description` (`str | None`) — Instance description.
- `data_schema` (`dict | None`) — The instance's data schema.
- `agent_surfaces` (`list[str] | None`) — Agent surfaces this instance is expected to be used from. Advisory; `None` means no opinion. Read as plain strings, so a value newer than your library version is returned rather than rejected.
- `agent_default_binding_tier` (`str | None`) — Default prominence for a binding (`autoload` / `available`). Advisory.
- `agent_engagement_hints` (`list[str] | None`) — Short routing phrases seeding when an agent should engage this instance.
- `agent_owner_instructions` (`str | None`) — The standing preference set for how agents should use this instance, rendered verbatim wherever it is shown.
- `agent_owner_instructions_epoch` (`int`) — Which edit of the instructions above this response describes. Pass it back as `expected_owner_instructions_epoch` to make an edit conflict-safe.
- `migration_id` (`str | None`) — Set only by `update_instance_schema` when a migration ran.
- `prior_version` (`int | None`) — Schema version before the migration (migration responses only).
- `new_version` (`int | None`) — Schema version after the migration (migration responses only).
- `migration_warnings` (`list[str] | None`) — Non-fatal migration warnings (migration responses only).

**`InstanceSchemaInfo`** — An instance's schema.
- `data_schema` (`dict`) — The instance's data schema.

**`WriteResult`** — Result of a synchronous write.
- `write_id` (`str`) — ID of the committed write.
- `changes` (`Any`) — What the write did, grouped into `created` / `updated` / `deleted` (null on responses from an older server). A drop-mode scoped write also carries `skipped_out_of_scope`, what the scope kept out.
- `trace_id` (`str | None`) — Correlate the call with API logs.

**`AsyncWriteResult`** — Acknowledgement of an enqueued write.
- `write_id` (`str`) — Poll completion with `write_status`.

**`WriteStatusResult`** — Status of an async write.
- `write_id` (`str`) — The write being polled.
- `write_status` (`WriteQueueStatus`) — Current queue status.
- `error_detail` (`str | None`) — Failure detail when `write_status` is `FAILED`.
- `completed_at` (`datetime | None`) — When the write completed.
- `changes` (`Any`) — What the completed write did, in the same shape `WriteResult.changes` carries — including `skipped_out_of_scope` for a drop-mode scoped write. `None` until the write completes.

**`ScopeObject`** — One concrete object a read or write scope may touch.
- `type` (`str`) — Object type — PascalCase class name or snake_case table name.
- `key` (`dict`, required) — The object's user-defined primary key, one entry per primary-key field. Only objects of a type that declares a primary key can be scoped.

**`ReadScope`** — A read's scope.
- `objects` (`list[ScopeObject]`) — The objects the read may see.
- `relations_scope` (`str`) — `"no_relations"` (default) or `"all_relations"`, which also exposes the relations among the scoped objects.

**`WriteScope`** — A write's scope.
- `objects` (`list[ScopeObject]`) — The objects the write is anchored to and confined to. There is no relation policy — relation changes follow the confinement rule rather than a mode of their own.
- `mode` (`str`) — `"reject"` (default) fails the whole write when it would touch an object outside the scope. `"drop"` skips those changes and reports them under `changes.skipped_out_of_scope`, and creates only objects the scope names by primary key — such an object need not be stored yet. Omitted from the request at its default. Defaults to `"reject"`.

**`ReadResult`** — Result of a read.
- `reader_result` (`Any`) — The formatted answer, shaped by `read_mode` (e.g. `{"answer": ...}`).
- `trace_id` (`str | None`) — Correlate the call with API logs.
- `related_types` (`RelatedTypes | None`) — The schema around the answer, when the read asked for it with `include_related_types`; `None` otherwise.

**`RelatedTypes`** — What else the memory could answer about, for a read that asked with `include_related_types`. The shape is documented on the [REST API](https://xmemory.ai/api/#related-types).
- `touched` (`list[RelatedTypesTouched]`) — Object types the read touched, each `{ object_type, fields_not_returned, related, omitted_related }` with `related` a list of `RelatedTypesLink` (`object_type`, `relation`, `touched_role`, `related_role`, `cardinality`). Empty when the read executed nothing.
- `objects` (`dict[str, RelatedTypesObjectType]`) — Every object type named in `touched` or reached within `depth` relation levels, once: `description`, `primary_key`, `fields`, and — on a read that asked for a depth above 1 — `distance` (listed edges from the nearest touched type; `None` otherwise), its own `related` edges below the last level, and `omitted_related`.
- `relations` (`dict[str, RelatedTypesRelation]`) — Every relation named by an edge, once: `description`.
- `depth` (`int`) — Relation levels asked for (`related_types_depth`; 1 when unset). Never lowered by the server: what its budgets kept the walk from listing is counted on `omitted_objects`.
- `omitted_touched` (`int`) — Touched types the server dropped to stay within its payload budget.
- `omitted_objects` (`int`) — Object types within `depth` relation levels in the schema that the catalog does not list: the route to them ran through an edge the server's budget cut, or their level was dropped for its byte budget.
- `truncated` (`bool`) — `True` when any type, edge or level was dropped for the budget.

**`ExtractResult`** — Result of an extract (nothing persisted).
- `objects_extracted` (`Any`) — The objects that would be stored (`objects` / `relations`).
- `trace_id` (`str | None`) — Correlate the call with API logs.

**`DescribeResult`** — Agent-facing tool descriptions, with `as_text(include_http=False)`, `as_anthropic_tools()`, and `as_openai_tools()` format helpers.
- `instance_id` (`str`) — The described instance.
- `instance_name` (`str`) — Instance name.
- `schema_summary` (`str`) — Human-readable schema summary.
- `tools` (`list[ToolDescription]`) — One entry per agent-callable tool.
- `purpose` (`str | None`) — What this memory is for — the instance description, under the name the agent-facing surfaces give it. Included in `as_text()`.
- `owner_instructions` (`str | None`) — The standing preference set for this instance, rendered verbatim. Included in `as_text()`, labelled with where it came from rather than as the library's own words.
- `usage_brief` (`str | None`) — Generated from the schema; `None` until generated, and cleared again by a schema change. Left out of `as_text()`, since it restates `schema_summary`.

**`GenerateSchemaResult`** — A generated schema.
- `data_schema` (`dict`) — The produced schema as a dict.

**`EnhanceSchemaResult`** — An evolved schema plus an executor-ready migration plan.
- `data_schema` (`dict`) — The produced schema as a dict.
- `migration_plan` (`MigrationPlan | None`) — Reconciled plan; pass to `update_instance_schema` / `dry_run_migration`.
- `summary` (`str | None`) — Human-readable summary of the change.
- `warnings` (`list[dict]`) — Non-fatal reconciler observations.
- `repair_log` (`list[dict]`) — Auto-repairs applied to the LLM output.

**`DryRunResult`** — Planned migration DDL, with nothing applied.
- `status` (`"ok"`) — Always `"ok"` for a successful dry run.
- `instance_id` (`str`) — The instance previewed against.
- `current_version` (`int`) — Current schema version.
- `statements` (`list[str]`) — The planned DDL statements.
- `warnings` (`list[str]`) — Non-fatal warnings.
- `plan_summary` (`PlanSummary`) — Per-op-type breakdown.
- `requires_metadata_sync` (`bool`) — Whether a metadata sync is needed after applying.

**`MigrationRecord`** — One applied-migration history row.
- `id` (`str`) — Migration record ID.
- `applied_at` (`datetime`) — When the migration was applied.
- `source` (`str`) — `direct` or `suggestion_engine`.
- `decided_by` (`str | None`) — Who decided the migration, if recorded.
- `prior_version` (`int`) — Schema version before.
- `new_version` (`int`) — Schema version after.
- `ops` (`list[dict]`) — The raw ops applied (kept as dicts for forward-compat).
- `ops_summary` (`PlanSummary`) — Per-op-type breakdown.
- `notes` (`str | None`) — Free-text notes.
- `yaml_before` (`str | None`) — Schema YAML before (only when `include_yaml=True`).
- `yaml_after` (`str | None`) — Schema YAML after (only when `include_yaml=True`).

**`ListMigrationsResult`** — A page of migration records, newest first.
- `status` (`"ok"`) — Always `"ok"`.
- `instance_id` (`str`) — The instance listed.
- `items` (`list[MigrationRecord]`) — The records on this page.
- `next_before_id` (`str | None`) — Pagination cursor; `None` at the tail.
- `has_more` (`bool`) — Whether more pages follow.

**`ReviewSuggestionsResult`** — The rolling schema-improvement proposal (or an in-progress status).
- `status` (`"ok" | "evolution_in_progress"`) — `evolution_in_progress` means a migration is in flight.
- `instance_id` (`str`) — The instance reviewed.
- `proposal` (`ConsolidatedProposal | None`) — The proposal when `status` is `"ok"`.
- `retry_after_seconds` (`int | None`) — Back-off hint when `evolution_in_progress`.

**`DecideSuggestionsResult`** — Result of recording a batch of decisions.
- `status` (`"ok"`) — Always `"ok"`.
- `instance_id` (`str`) — The instance decided on.
- `decisions_recorded` (`list[RecordedDecision]`) — One per recorded decision.
- `warnings` (`list[DependencyWarning]`) — Advisory dependency warnings.
- `next_proposal_version` (`str`) — Token to pass to `apply_pending_decisions`.

**`ApplyPendingDecisionsResult`** — Result of applying accepted decisions as one migration.
- `status` (`"ok" | "nothing_to_apply"`) — `nothing_to_apply` when no accepted items remained.
- `instance_id` (`str`) — The instance applied to.
- `migration_id` (`str | None`) — The committed migration's ID (when one ran).
- `prior_version` (`int`) — Schema version before.
- `new_version` (`int`) — Schema version after.
- `applied_items` (`list[str]`) — Item fingerprints that were applied.
- `summary` (`str`) — Human-readable summary.
- `warnings` (`list[str]`) — Non-fatal warnings.
- `notes` (`list[str]`) — Additional notes.

**`MigrationPlan`** — An ordered sequence of migration ops — the wire format emitted by `enhance_schema` and consumed by `update_instance_schema` / `dry_run_migration`.
- `ops` (`list[MigrationOp]`) — The ordered ops.

**`MigrationOp`** — One migration operation — a discriminated union keyed on `op_type`.
- `op_type` (`str`) — Discriminator: one of `add_object`, `remove_object`, `rename_object`, `change_object`, `add_field`, `remove_field`, `rename_field`, `change_field`, `add_relation`, `remove_relation`, `rename_relation`, `change_relation` — each variant carries operation-specific parameters.

**`PlanSummary`** — Per-op-type breakdown of a migration plan.
- `count_by_op_type` (`dict[str, int]`) — Op count keyed by `op_type`.
- `total` (`int`) — Total number of ops.

**`ConsolidatedProposal`** — The single rolling improvement proposal for an instance.
- `instance_id` (`str`) — The instance the proposal is for.
- `proposal_version` (`str`) — Optimistic-concurrency token for `decide_suggestions` / `apply_pending_decisions`.
- `schema_version` (`int`) — Schema version the proposal targets.
- `items` (`list[ProposalItem]`) — The accept/reject/defer-able items.
- `generated_at` (`datetime`) — When the proposal was generated.
- `notes` (`list[str]`) — Proposal-level notes.

**`ProposalItem`** — One decide-able item inside a proposal.
- `item_fingerprint` (`str`) — Stable ID; pass in a `DecisionInput`.
- `op` (`dict`) — The proposed op (raw dict; parse with `parse_migration_op`).
- `evidence_feedback_ids` (`list[str]`) — Feedback IDs that motivated the item.
- `evidence_query_samples` (`list[str]`) — Sample queries that motivated the item.
- `frequency` (`int`) — How often the supporting signal occurred.
- `depends_on` (`list[str]`) — Fingerprints this item depends on.
- `current_decision` (`str | None`) — Existing decision, if any.
- `rationale` (`str`) — Why the item was proposed.
- `apply_blocked` (`bool`) — `True` when the change cannot be applied as proposed (for example a field another item already adds as part of a new object); `rationale` says why. Accepting it fails the whole apply, so leave it out of a bulk accept. `False` when the server sends no flag. Requires `xmemory-ai>=0.22.0`.

**`DecisionInput`** — One decision in a `decide_suggestions` batch.
- `item_fingerprint` (`str`) — The `ProposalItem` to decide on.
- `decision` (`str`) — One of `accept`, `reject`, `defer`.
- `edits` (`dict | None`) — Optional op override (must keep the same `op_type`).

**`RecordedDecision`** — A persisted decision.
- `item_fingerprint` (`str`) — The decided item.
- `decision_id` (`str`) — ID of the recorded decision.

**`DependencyWarning`** — An advisory (non-blocking) dependency warning from `decide_suggestions`.
- `kind` (`str`) — Warning kind.
- `item_fingerprint` (`str`) — The item the warning concerns.
- `related_fingerprints` (`list[str]`) — Related item fingerprints.
- `related_summaries` (`list[str]`) — Human-readable summaries of the related items.
- `guidance` (`str`) — What to do about it.

**`ToolDescription`** — One agent-callable tool in a DescribeResult.
- `name` (`str`) — Tool name.
- `description` (`str`) — What the tool does.
- `when_to_use` (`str`) — Guidance on when to call it.
- `parameters` (`list[ToolParameterDescription]`) — The tool's parameters.
- `http_method` (`str`) — Underlying HTTP method.
- `http_path` (`str`) — Underlying HTTP path.

**`ToolParameterDescription`** — One parameter of a ToolDescription.
- `name` (`str`) — Parameter name.
- `type` (`str`) — Parameter type.
- `description` (`str`) — What the parameter is.
- `required` (`bool`) — Whether it must be supplied.
- `enum` (`list[str] | None`) — Allowed values, if constrained.
- `default` (`str | None`) — Default value, if any.

**Enums**

| Enum | Values |
| --- | --- |
| `SchemaType` | `YML`, `JSON` |
| `ExtractionLogic` | `FAST`, `DEEP` |
| `ReadMode` | `SINGLE_ANSWER`, `RAW_TABLES`, `XRESPONSE` |
| `RelatedTypesMode` | `NONE`, `TYPES` |
| `RelationCardinality` | `ONE_TO_ONE`, `ONE_TO_MANY`, `MANY_TO_ONE`, `MANY_TO_MANY`, `UNCONSTRAINED` |
| `WriteQueueStatus` | `QUEUED`, `PROCESSING`, `EXTRACTING`, `EXTRACTED`, `APPLYING`, `COMPLETED`, `FAILED`, `NOT_FOUND` |

**Exceptions**

| Exception | Parent | Attributes |
| --- | --- | --- |
| `XmemoryAPIError` | `Exception` | `.status`, `.code`, `.details`, `.retry_after` (each `None` when absent) |
| `XmemoryHealthCheckError` | `XmemoryAPIError` | `.status` (HTTP status code or `None`) |
