Skip to content

API

The xmemory REST API is what the Python and 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 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. Copy and securely store it — never share your API key publicly.


Every endpoint wraps its result in a standard envelope:

{
"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. (The Python and 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).


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

Terminal window
# 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 and Instances to create one.


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 — and both accept a scope that anchors a text write to records you already know about, see Scoped writes.

Endpoints

POST/instances/{instance_id}/writePersist data — extract structured objects from text, or apply explicit structured mutations (blocks until committed)
textstring
Free-form text to extract from. Exactly one of text / structured_mutations must be provided.
structured_mutationsarray
Ordered list of deterministic, LLM-free object create/update/delete and relation create/delete mutations — see Structured writes. Mutually exclusive with text.
extraction_logicstring
"fast" (default) or "deep". Text writes only. Defaults to "fast".
trace_idstring
Caller-supplied ID echoed back on the response for log correlation.
session_idstring
Session ID for grouping related calls in tracing.
use_diff_engineboolean
Override the instance's diff-engine setting for this write. Text writes only.
scopeobject
Anchor a text write to specific existing records: { "objects": [...] }, each entry { "type": ..., "key": { "key": { <pk field>: <value> } } }, naming one record by its user-defined primary key. Their current values steer the extractor to update them instead of duplicating them, and the write is confined to them — touching any other existing record fails. Text writes with fast extraction only, and it additionally requires read permission on the instance. See Scoped writes.
write_idstring
ID of the committed write.
cleaned_objectsobject
The stored objects, as { objects: [...], relations: [...] }.
changesobject
What the write did, grouped into created / updated / deleted (each { objects: [...], relations: [...] }; updated object entries carry per-field old/new values).
trace_idstring
Echoed trace id for log correlation.
{
"write_id": "...",
"cleaned_objects": { "objects": [], "relations": [] },
"changes": { "created": { "objects": [], "relations": [] }, "updated": { "objects": [], "relations": [] }, "deleted": { "objects": [], "relations": [] } },
"trace_id": "..."
}
POST/instances/{instance_id}/write_asyncSame write pipeline as write, but returns immediately instead of blocking
textstring
Free-form text to extract from. Exactly one of text / structured_mutations must be provided.
structured_mutationsarray
Ordered list of deterministic, LLM-free create/update/delete mutations — see Structured writes. Mutually exclusive with text.
extraction_logicstring
"fast" (default) or "deep". Text writes only. Defaults to "fast".
trace_idstring
Caller-supplied ID echoed back for log correlation.
session_idstring
Session ID for grouping related calls in tracing.
use_diff_engineboolean
Override the instance's diff-engine setting for this write. Text writes only.
scopeobject
Anchor a text write to specific existing records: { "objects": [...] }, each entry { "type": ..., "key": { "key": { <pk field>: <value> } } }, naming one record by its user-defined primary key. Their current values steer the extractor to update them instead of duplicating them, and the write is confined to them — touching any other existing record fails. Text writes with fast extraction only, and it additionally requires read permission on the instance. See Scoped writes.
write_idstring
ID to poll with writeStatus. Do not read immediately after an async write — the data may not be committed yet.
{ "write_id": "..." }
POST/instances/{instance_id}/write_statusCheck whether an async write has finished
write_idstringrequired
ID returned by writeAsync.
write_statusstring
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_detailstring
Human-readable failure detail when write_status is failed; otherwise null.
errorobject
Structured error (code, message) when the write failed; otherwise null.
completed_atstring
ISO-8601 timestamp when the write finished; null until then.
{
"write_id": "...",
"write_status": "completed",
"error_detail": null,
"error": null,
"completed_at": "2025-03-16T12:34:56Z"
}

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:

Terminal window
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.

  • createkey (primary-key fields) plus values (the remaining fields).
  • updatekey plus values; a null value clears that field.
  • deletekey only.

Relation operations.

  • createendpoints (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."


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

Endpoints

POST/instances/{instance_id}/readQuery the instance in natural language
querystringrequired
Natural-language question.
modestring
"single-answer" (default), "xresponse", or "raw-tables". Defaults to "single-answer".
scopeobject
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.
return_sqlboolean
Include the executed SQL in the response (sql). Defaults to false.
include_related_typesstring
"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. Defaults to "none".
related_types_depthinteger
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. Defaults to 1.
skip_suggestion_captureboolean
Skip recording read-gap signals for the schema-suggestion engine. Defaults to false.
trace_idstring
Caller-supplied ID echoed back for log correlation.
session_idstring
Session ID for grouping related calls in tracing.

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 null when the read matched no rows Raw SQL results

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), 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.


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.

Terminal window
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:

{
"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
}
}
  • 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). 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.

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.

{
"query": "Which courses require an English test?",
"include_related_types": "types",
"related_types_depth": 2
}
"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".

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.


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:

{
"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.


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.

Add a scope field to the /read endpoint:

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

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")

Section titled “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.
Terminal window
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"}}}
]
}
}'
Terminal window
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.

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.

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

{
"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.


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. It does two things at once:

  • It tells the extractor what to integrate with. The scoped records’ current field values are put in front of the extractor, so the new information is folded into them instead of producing a near-duplicate.
  • It confines the result. The write may only modify or delete the scoped records, and create new records and relations anchored to them. A write that would touch any other existing record fails instead of applying partially. This half is checked against the resulting plan rather than asked of the extractor, so it holds regardless of what the extraction produced.

Add a scope field alongside text:

{
"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"}}
}
]
}
}

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: the relations among the scoped records always accompany the extraction hint, and relation changes are governed by the confinement rule instead of a mode.

Update a known record instead of duplicating it

Section titled “Update a known record instead of duplicating it”
Terminal window
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: Alice’s current values are shown to the extractor, so role and phone are updated on the existing record. A second Person for Alice is not created, and no other record in the instance can be touched by this write.

Scope everything the write is allowed to change

Section titled “Scope everything the write is allowed to change”
Terminal window
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 — so anything the write should be able to edit belongs in the scope.

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 scoped records’ current field values ride the extraction prompt, which makes a scope-carrying write a read of those rows. An API key with write access alone is refused with HTTP 403.

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

{
"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 "extraction_logic": "deep" Scope currently works with fast extraction only.
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 no record or more than one Scope references are resolved before the write is accepted, so a bad reference fails fast rather than silently widening.

This differs from a scoped read on purpose. A read narrows past a record that is not stored; a write rejects it. The two scopes are not the same kind of thing. A read’s scope is the set of records a question is about, and absence answers the question. A write’s scope is the boundary the write is confined to, checked against the resulting plan — narrow it to nothing and a confined write silently becomes an unconfined one, free to create whatever the extractor produced. So every record a write scope names has to exist.

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.

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


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

Endpoints

POST/instances/{instance_id}/extractExtract structured objects from text without storing them — preview what a write would produce
textstringrequired
Free-form text to extract from.
extraction_logicstring
"fast" (default) or "deep". Same parameters as write. Defaults to "fast".
trace_idstring
Caller-supplied ID echoed back for log correlation.
session_idstring
Session ID for grouping related calls in tracing.
objects_extractedobject
The extracted (but not stored) objects, as { objects: [...], relations: [...] }.
trace_idstring
Echoed trace id for log correlation.
{ "objects_extracted": { "objects": [], "relations": [] }, "trace_id": "..." }

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

Endpoints

GET/instances/{instance_id}/describeReturn the agent-facing tool descriptions for an instance, with its schema in schema_summary
instance_idstring
The instance the tools operate on.
instance_namestring
Human-readable instance name.
schema_summarystring
Human-readable description of the instance's schema, suitable for including in an LLM system prompt.
toolsarray
Tool descriptions; only includes tools the caller has permission to use.
purposestring
What this memory is for — the instance description, under the name the agent-facing surfaces give it. null when the instance has none.
owner_instructionsstring
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_briefstring
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.
{
"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 are the top-level container that holds instances. List the clusters you can access, or fetch one by ID.

Endpoints

GET/clustersList all clusters accessible to the authenticated user

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

Terminal window
curl https://api.xmemory.ai/clusters \
-H "Authorization: Bearer $XMEM_API_KEY"
GET/clusters/{cluster_id}Get a single cluster by ID

A single cluster { id, org_id, name, description }.

Terminal window
curl https://api.xmemory.ai/clusters/$XMEM_CLUSTER_ID \
-H "Authorization: Bearer $XMEM_API_KEY"

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 for supported constructs and deliberate extraction tuning.

Endpoints

POST/clusters/{cluster_id}/instances/generate_schemaGenerate an xmemory schema from a plain-language description
schema_descriptionstringrequired
Plain-language description of the data to track.
current_yml_schemastring
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.).
data_schemaobject
The generated schema. When current_yml_schema is omitted (create-from-scratch), only data_schema is returned.
migration_planobject
Structured plan to apply the change; pass it to the dry-run and update endpoints. See Schema evolution.
summarystring
Human-readable summary of the change.
warningsarray
Non-fatal warnings raised while generating or enhancing the schema.
repair_logarray
Automatic corrections applied to make the generated schema valid.
{
"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": []
}

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.

Endpoints

POST/clusters/{cluster_id}/instancesCreate an instance from a generated schema
namestringrequired
Instance name.
descriptionstring
Description.
instance_schemaobjectrequired
Use "yml": { "value": "..." } for YAML schemas, or "json_schema": { "value": "..." } for JSON.
schema_descriptionstring
Plain-language description stored with the instance.
generate_trace_idstring
Trace id linking this instance to the generateSchema call that produced its schema.
idstring
The new instance ID. Save it — you'll pass it with every subsequent call.
cluster_idstring
The cluster the instance was created in.
namestring
Instance name.
descriptionstring
Instance description, if set.
data_schemaobject
The instance's active schema.
{
"id": "...",
"cluster_id": "...",
"name": "contacts",
"description": null,
"data_schema": { "...": "..." }
}
GET/instancesList all instances accessible to the authenticated user

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

Terminal window
curl https://api.xmemory.ai/instances \
-H "Authorization: Bearer $XMEM_API_KEY"
GET/instances/{instance_id}Get a single instance by ID
idstring
Instance ID.
cluster_idstring
The cluster the instance belongs to.
namestring
Instance name.
descriptionstring
Instance description, if set.
created_onstring
ISO-8601 creation timestamp.
created_by_emailstring
Email of the account that created the instance.
data_schemaobject
The instance's active schema.
GET/instances/{instance_id}/schemaGet the current schema of an instance
data_schemaobject
The instance's active schema (objects, fields, relations).
PUT/instances/{instance_id}/schemaUpdate the schema of an existing instance
instance_schemaobjectrequired
The new schema. A purely additive change (new objects/fields/relations) needs only this field.
migration_planobject
Required for a non-additive change (rename, remove, type change); pass the plan from generateSchema.
confirm_destructiveboolean
Set true for any op that drops data. Defaults to false.
migration_idstring
Present when a migration ran (a non-additive change).
prior_versioninteger
Schema version before this update.
new_versioninteger
Schema version after this update.
migration_warningsarray
Non-fatal warnings emitted by the migration.

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.

PUT/instances/{instance_id}Update instance metadata (name and description)
namestring
New instance name.
descriptionstring
Updated description.
agent_owner_instructionsstring
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_epochinteger
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_surfacesarray
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_tierstring
Default prominence for a binding: autoload or available. Advisory.
agent_engagement_hintsarray
Short routing phrases describing when an agent should engage this instance. At most 16, each at most 200 characters.
idstring
Instance ID.
namestring
Updated instance name.
descriptionstring
Updated description.

A key absent from the body is left as it is, not cleared — so a client that predates a field cannot wipe it by not knowing about it.

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.

PATCH/instances/{instance_id}Update some instance metadata fields, leaving the rest alone
namestring
New instance name.
descriptionstring
Updated description (send null to clear).
agent_surfacesarray
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_tierstring
Default prominence for a binding: autoload or available. Advisory.
agent_engagement_hintsarray
Short routing phrases describing when an agent should engage this instance. At most 16, each at most 200 characters.
agent_owner_instructionsstring
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.
idstring
Instance ID.
namestring
Instance name.
descriptionstring
Instance description.

Every field is independent: omit a key and the stored value is untouched, send null to clear it. PUT accepts the same three agent_* hints, so reach for this one when you are changing a field on its own rather than saving a whole form; PUT is the only one that accepts expected_owner_instructions_epoch.

name may be updated but not cleared — sending "name": null is rejected. See Structured errors.

GET/instances/{instance_id}/agent_setupHow to connect this instance on an agent surface, most likely first
formatstring
"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".
instance_idstring
The instance these steps connect.
instance_namestring
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_urlstring
The install page, for a reader who would rather follow it than the steps.
surfacesarray
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_agentstring
One line a person can paste into an agent instead of running the steps by hand.
formatstring
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.
projectobject
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.

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

A list of deleted instance IDs.


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.

Endpoints

POST/instances/{instance_id}/migrations/dry_runPreview a migration without applying it
instance_schemaobjectrequired
The proposed new schema (same body shape as the schema update).
migration_planobject
Plan for a non-additive change; pass the plan from generateSchema.
confirm_destructiveboolean
Set true for any op that drops data. Defaults to false.
statementsarray
The planned DDL statements.
current_versioninteger
The instance's current schema version (the migration would advance it).
plan_summaryobject
Counts of ops by type: { count_by_op_type, total }.
requires_metadata_syncboolean
Whether applying would also resync derived metadata.
{
"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
}

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.

GET/instances/{instance_id}/migrationsList applied migrations, newest first
limitinteger
Range 1–200. Defaults to 50.
before_idstring
UUID pagination cursor.
include_yamlboolean
Adds before/after schema snapshots. Defaults to false.
itemsarray
Migration records (see getMigration for the per-item shape). source is direct or suggestion_engine.
next_before_idstring
Pass as before_id to fetch the next (older) page; null on the last page.
has_moreboolean
Whether more records exist beyond this page.
{
"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
include_yamlboolean
Adds the before/after schema snapshots (yaml_before / yaml_after). Defaults to false.
idstring
Migration record ID.
applied_atstring
ISO-8601 timestamp the migration was applied.
sourcestring
direct or suggestion_engine.
prior_versioninteger
Schema version before the migration.
new_versioninteger
Schema version after the migration.
opsarray
The migration operations that were applied.
ops_summaryobject
Counts of ops by type.
yaml_beforestring
Schema YAML before the migration (only when include_yaml: true).
yaml_afterstring
Schema YAML after the migration (only when include_yaml: true).

Suggestion engine — review → decide → apply

Section titled “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.

Endpoints

POST/instances/{instance_id}/suggestions/reviewReturn the consolidated proposal and a proposal_version concurrency token
session_idstring
Body may be {} or { "session_id": "..." }.
proposalobject
Holds proposal_version (concurrency token), schema_version, and items.
{
"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"] }
]
}
}

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/decideRecord an accept / reject / defer per item in one batch
proposal_versionstringrequired
The proposal_version from review.
decisionsarrayrequired
One { "item_fingerprint": "...", "decision": "accept" | "reject" | "defer" } per item.
session_idstring
Session ID for tracing.

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

{
"proposal_version": "ab12...",
"decisions": [ { "item_fingerprint": "fp1", "decision": "accept" } ]
}

A proposal_version that no longer matches the current proposal returns stale_proposal_version; malformed decisions return invalid_decision_input. See Structured errors.

POST/instances/{instance_id}/suggestions/applyCommit accepted decisions as one migration
proposal_versionstringrequired
The next_proposal_version from decide.
session_idstring
Session ID for tracing.

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.

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

A stale token returns stale_proposal_version; an unmet dependency returns dependency_closure_failed; a failed commit returns apply_failed. See Structured errors.

POST/instances/{instance_id}/pending-feedback/discardClear the pending schema-improvement backlog for this instance
rows_discardedinteger
Number of pending feedback signals that were discarded.
instance_idstring
The instance whose backlog was cleared.
{ "status": "ok", "instance_id": "...", "rows_discarded": 12 }

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

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

{ "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.

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.


Endpoints

GET/healthzReturns 200 if the API is reachable (no authentication required)
Terminal window
curl https://api.xmemory.ai/healthz

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.

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.
HTTP code Meaning Retryable?
503 SERVICE_UNAVAILABLE The operation ran out of its server-side time budget before it could finish. Yes — retry the same request.
503 INTERNAL_ERROR The operation was abandoned after repeated transient failures. Yes — retry, but back off further; the server already retried.

A write (or another long-running operation) did not complete within the server’s time budget. Nothing about the request is wrong, and nothing was partially applied — the operation failed as a unit, and its final state is failed.

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

The exact message names the stage that ran out of time, so treat it as human-facing text rather than something to match on. The trace_id it carries is the correlation key for the operation in the console — log it if you are reporting the failure.

This is retryable, and unlike 429 it carries no Retry-After — retry the same request, ideally with backoff.

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: SERVICE_UNAVAILABLE means the clock ran out, while INTERNAL_ERROR on a 503 means the server exhausted its own retries first.

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

{
"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.

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.