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.
Response envelope
Section titled “Response envelope”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).
Try it
Section titled “Try it”Write something, then read it back — two curl calls:
# Writecurl -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" }'
# Readcurl -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.
Writing
Section titled “Writing”Send raw text; xmemory extracts typed objects from it and persists them. write blocks until committed; write_async returns immediately and you poll write_status for completion. Both endpoints alternatively accept structured_mutations for deterministic, extraction-free edits — see Structured writes — 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)
Parameters
textstringtext / structured_mutations must be provided.structured_mutationsarraytext.extraction_logicstring"fast" (default) or "deep". Text writes only. Defaults to "fast".trace_idstringsession_idstringuse_diff_enginebooleanscopeobject{ "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.Returns
write_idstringcleaned_objectsobject{ objects: [...], relations: [...] }.changesobjectcreated / updated / deleted (each { objects: [...], relations: [...] }; updated object entries carry per-field old/new values).trace_idstringExample response
{ "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
Parameters
textstringtext / structured_mutations must be provided.structured_mutationsarraytext.extraction_logicstring"fast" (default) or "deep". Text writes only. Defaults to "fast".trace_idstringsession_idstringuse_diff_enginebooleanscopeobject{ "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.Returns
write_idstringwriteStatus. Do not read immediately after an async write — the data may not be committed yet.Example response
{ "write_id": "..." }POST/instances/{instance_id}/write_statusCheck whether an async write has finished
Parameters
write_idstringrequiredwriteAsync.Returns
write_statusstringqueued, 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_detailstringwrite_status is failed; otherwise null.errorobjectcode, message) when the write failed; otherwise null.completed_atstringExample response
{ "write_id": "...", "write_status": "completed", "error_detail": null, "error": null, "completed_at": "2025-03-16T12:34:56Z"}Structured writes
Section titled “Structured writes”When you already know exactly what to store, skip extraction entirely: pass structured_mutations instead of text (exactly one of the two — the request is rejected if both or neither are present). Each mutation is applied deterministically, with no LLM involved, making structured writes fast and exactly repeatable.
A mutation is tagged object_mutation or relation_mutation. Objects support create, update, and delete; relations support endpoint-based create and delete:
curl -X POST https://api.xmemory.ai/instances/$XMEM_INSTANCE_ID/write \ -H "Authorization: Bearer $XMEM_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "structured_mutations": [ { "object_mutation": { "object_type": "person", "create": { "key": { "email": "alice@acme.com" }, "values": { "name": "Alice Johnson" } } } }, { "relation_mutation": { "relation_type": "works_at", "create": { "endpoints": [ { "object_name": "person", "key": { "email": "alice@acme.com" } }, { "object_name": "company", "key": { "name": "Acme Corp" } } ] } } } ] }'Addressing objects. key identifies an object by its user-defined primary-key fields (as above) — or, for update / delete, by { "xuid": "..." } (the server-generated unique ID returned in changes). create never takes an xuid; it is generated server-side.
Object operations.
create—key(primary-key fields) plusvalues(the remaining fields).update—keyplusvalues; anullvalue clears that field.delete—keyonly.
Relation operations.
create—endpoints(one per relation role:object_name+ that object’skey).delete— match byendpoints(a subset is allowed) orkey; deleting more than one matched row requires"allow_bulk_delete": true.
The endpoint keys above are a caller-addressing mechanism for deterministic structured mutations,
not an XMD schema requirement. Natural-language extraction can create relations between objects
with primary_key: []; xmemory links those concrete records internally. Do not add an application
key or generated UUID merely to enable relation extraction.
XMD v1 relations do not have their own scalar fields. When a relationship needs data such as a role, quantity, or date, model that relationship as an object and connect it to the participants with relations.
Ordering and batches. Mutations apply in list order, and later mutations may reference objects created earlier in the same batch (e.g. create a person, then a relation pointing at it). Multiple mutations of the same object in one batch follow a strict contract: compatible sequences compose (create then update applies the update to the created row; update then delete nets out to a delete; duplicate deletes are no-ops), while contradictory ones — create then delete, or anything after a delete — are rejected with a “split into separate writes” error rather than silently reordered.
Response. The same shape as a text write; changes reports exactly what was created / updated / deleted, with per-field old/new values on object updates. write_async accepts structured_mutations identically — poll writeStatus and read changes from the completed status.
Availability. Structured writes can be disabled per deployment; when off, the request fails with "Structured writes are not enabled on this deployment."
Reading
Section titled “Reading”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
Parameters
querystringrequiredmodestring"single-answer" (default), "xresponse", or "raw-tables". Defaults to "single-answer".scopeobject{ "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_sqlbooleansql). 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_depthintegerinclude_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_capturebooleanfalse.trace_idstringsession_idstringReturns
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.
Related types
Section titled “Related types”A read can also say what else the memory could answer about. Pass include_related_types: "types" and the response carries related_types: the object types the read touched, each with the fields the read did not return and the object types a declared relation links it to, plus a catalog describing every named type and relation once. It is derived from the instance schema and the statements the read executed — no extra rows, no model call — so an agent can ask a deliberate follow-up instead of guessing what the store holds.
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 }}What each part means
Section titled “What each part means”touched— one entry per object type the read’s SQL read from.fields_not_returnedlists the fields of that type the read did not project, so a follow-up can ask for one by name.relatedlists 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, orunconstrainedwhen the relation declares no unique key (see Relations). A self-relation lists one edge per role.objectsandrelations— the catalog: every type a listed edge names, described once (description, primary key, field names), and every relation named by an edge, once. Withrelated_types_depthabove 1 each entry also carries itsdistanceand, below the last level, its ownrelatededges (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, andtruncatedsays that something was dropped.
The key is present only when the request asked for it; touched is empty when no executed statement read a declared object type. The option composes with mode and scope, and a scoped read still lists neighbouring types outside its scope, since those are schema names the caller could read from the schema endpoint anyway.
Following relations further
Section titled “Following relations further”related_types_depth (1 to 3, default 1) follows the listed edges further than the touched types’ own. Level 1 is the types those edges name; each further level expands the previous one, so every entry under objects carries its distance — listed edges from the nearest touched type: 0 for a touched type, 1 for a type a touched type’s listed edge names, and so on — and an entry between the touched types and the last level carries its own related edges, so the walk can be continued from the catalog. The response echoes the depth asked for as depth and counts on omitted_objects every type within that many relation levels in the schema that the catalog does not list.
{ "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".
Permissions
Section titled “Permissions”Related types are schema metadata — the same information GET /instances/{id}/schema and /describe carry — so an API key needs instance.get_own in addition to data.read for the option. A key without it is refused with HTTP 403, code FORBIDDEN, and a message that names the permission; the plain read is unaffected. related_types_depth needs nothing beyond the option.
Composite questions
Section titled “Composite questions”A question holding several independent parts — “who owns the billing service, and when did the last incident close?” — is answered in one call. The server splits it into sub-questions, answers each on its own, and runs them against one shared snapshot, so the parts cannot disagree with each other. Nothing has to be enabled or passed.
The response then carries reader_results alongside the usual reader_result, with one entry per sub-question:
{ "reader_result": { "answer": "Platform owns billing. The last incident closed on 3 March." }, "reader_results": [ { "sub_query": "who owns the billing service?", "reader_result": { "answer": "Platform." }, "error": null }, { "sub_query": "when did the last incident close?", "reader_result": { "answer": "3 March." }, "error": null } ]}Each entry’s reader_result is in whatever mode you asked for, so a raw-tables composite question returns one table per part. The top-level reader_result stays the combined answer, so a client that ignores reader_results keeps working.
Two things to code against. The key is absent for a single-intent question rather than being a one-entry list, so check whether it is present rather than counting its length. And a part that could not be answered sets its own error and leaves the other answers intact, so a composite read can be partly successful — handle error per entry rather than assuming the whole call failed.
A scope, if you supply one, applies read-only to every sub-question. sqls carries the per-sub-question SQL, gated exactly like sql.
Scoped reads
Section titled “Scoped reads”Restrict a query to a specific set of objects in your instance, limiting data access to only what you specify. Useful for multi-tenant systems, privacy controls, and compliance requirements.
Request format
Section titled “Request format”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" }}Scope structure
Section titled “Scope structure”objects (required)
Section titled “objects (required)”List of one or more objects to query. Each object has:
type(string): The object type (e.g.,"Person","Order").key(object): The object’s user-defined primary key, nested as{"key": {"email": "alice@example.com"}}, with one entry for every primary-key field. Only objects of a type that declares a primary key can be scoped.
relations_scope (optional, default: "no_relations")
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.
Examples
Section titled “Examples”Query a single object
Section titled “Query a single object”curl -X POST https://api.xmemory.ai/instances/your-instance-id/read \ -H "Authorization: Bearer $XMEM_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "query": "What is their email and phone number?", "scope": { "objects": [ {"type": "Person", "key": {"key": {"name": "Alice Johnson"}}} ] } }'Query multiple objects with relations
Section titled “Query multiple objects with relations”curl -X POST https://api.xmemory.ai/instances/your-instance-id/read \ -H "Authorization: Bearer $XMEM_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "query": "Which projects do these people work on together?", "scope": { "objects": [ {"type": "Person", "key": {"key": {"email": "alice@example.com"}}}, {"type": "Person", "key": {"key": {"email": "bob@example.com"}}} ], "relations_scope": "all_relations" } }'Semantics: The scope is limited to the two specified Person objects. With "relations_scope": "all_relations", relations these Person objects are part of become visible.
Records that are not there
Section titled “Records that are not there”A scope entry naming a record the instance does not hold is not an error on a read. It drops out of the scope, and the read answers from whatever is left:
- Every entry unmatched — HTTP 200 with the empty result for the mode.
raw-tablesreturns no rows,xresponseno objects,single-answersays nothing is stored for the records you scoped to. - Some entries unmatched — the read answers from the records that do exist, as if only those had been named.
This is what lets you ask about a record before you know whether it is there. A read is a question, and “nothing is stored under that key” answers it — the same way an unscoped read answers when it finds nothing.
The trade-off is that an aggregate is computed over the records that were found, not over the
entries you sent. Scope two people, ask “how many?”, and if one of them was deleted since you
last read it, the answer is 1 — correct for the scope that survived, and not what you meant to
ask. Nothing in the response reports the difference. When it matters that every entry resolved,
read the records back and check, rather than trusting a count over them.
Error handling
Section titled “Error handling”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.
Scoped writes
Section titled “Scoped writes”A write is normally free to touch anything in the instance: the extractor sees the text alone, and whatever it produces is reconciled against every record you have. That is the right default for “here is something new”, and the wrong one for “here is more about this record” — a note about someone the instance already knows can just as easily land as a second, near-identical record.
A scope on /write or /write_async names the records the write is about. 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.
Request format
Section titled “Request format”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"}} } ] }}Scope structure
Section titled “Scope structure”objects (required)
Section titled “objects (required)”One or more existing records the write is anchored to. Each entry has:
type(string): The object type, as the PascalCase class name ("Person") or the snake_case table name ("person").key(object): The record’s user-defined primary key, nested as{"key": {"name": "Alice Johnson"}}, with one entry for every primary-key field. Only records of a type that declares a primary key can be scoped — a scope names records by that key, so a type declaredprimary_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.
Examples
Section titled “Examples”Update a known record instead of duplicating it
Section titled “Update a known record instead of duplicating it”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”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.
Permissions
Section titled “Permissions”A scoped write additionally requires read permission on the instance, on top of the write permission any write needs. A scope is not merely a restriction — the 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.
Error handling
Section titled “Error handling”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."
Extracting
Section titled “Extracting”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
Parameters
textstringrequiredextraction_logicstring"fast" (default) or "deep". Same parameters as write. Defaults to "fast".trace_idstringsession_idstringReturns
objects_extractedobject{ objects: [...], relations: [...] }.trace_idstringExample response
{ "objects_extracted": { "objects": [], "relations": [] }, "trace_id": "..." }Describing
Section titled “Describing”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
Returns
instance_idstringinstance_namestringschema_summarystringtoolsarraypurposestringnull when the instance has none.owner_instructionsstringusage_briefstringnull until it has been generated, and cleared again by a schema change, so treat a null here as ordinary rather than as an error.Example response
{ "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
Section titled “Clusters”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
Returns
An array of clusters, each { id, org_id, name, description }.
Example request
curl https://api.xmemory.ai/clusters \ -H "Authorization: Bearer $XMEM_API_KEY"GET/clusters/{cluster_id}Get a single cluster by ID
Returns
A single cluster { id, org_id, name, description }.
Example request
curl https://api.xmemory.ai/clusters/$XMEM_CLUSTER_ID \ -H "Authorization: Bearer $XMEM_API_KEY"Schema generation
Section titled “Schema generation”Turn a plain-language description into typed XMD when generation is easier than authoring the contract directly. Both paths are supported: an agent can write and validate XMD itself, or call this operation with the memory requirements and pass the returned XMD to instance creation. Use the XMD guide for supported constructs and deliberate extraction tuning.
Endpoints
POST/clusters/{cluster_id}/instances/generate_schemaGenerate an xmemory schema from a plain-language description
Parameters
schema_descriptionstringrequiredcurrent_yml_schemastringReturns
data_schemaobjectcurrent_yml_schema is omitted (create-from-scratch), only data_schema is returned.migration_planobjectsummarystringwarningsarrayrepair_logarrayExample response
{ "data_schema": { "...": "..." }, "migration_plan": { "ops": [ { "op_type": "rename_field", "object_name": "person", "old_name": "mail", "new_name": "email" } ] }, "summary": "Rename person.mail to person.email.", "warnings": [], "repair_log": []}Instances
Section titled “Instances”An instance is a memory store with a typed schema, living inside a cluster — create one before you write or read. These endpoints manage its whole lifecycle: create, list, fetch, update its schema or metadata, and delete.
Endpoints
POST/clusters/{cluster_id}/instancesCreate an instance from a generated schema
Parameters
namestringrequireddescriptionstringinstance_schemaobjectrequired"yml": { "value": "..." } for YAML schemas, or "json_schema": { "value": "..." } for JSON.schema_descriptionstringgenerate_trace_idstringgenerateSchema call that produced its schema.Returns
idstringcluster_idstringnamestringdescriptionstringdata_schemaobjectExample response
{ "id": "...", "cluster_id": "...", "name": "contacts", "description": null, "data_schema": { "...": "..." }}GET/instancesList all instances accessible to the authenticated user
Returns
An array of instances (see getInstance for the per-item shape).
Example request
curl https://api.xmemory.ai/instances \ -H "Authorization: Bearer $XMEM_API_KEY"GET/instances/{instance_id}Get a single instance by ID
Returns
idstringcluster_idstringnamestringdescriptionstringcreated_onstringcreated_by_emailstringdata_schemaobjectGET/instances/{instance_id}/schemaGet the current schema of an instance
Returns
data_schemaobjectPUT/instances/{instance_id}/schemaUpdate the schema of an existing instance
Parameters
instance_schemaobjectrequiredmigration_planobjectgenerateSchema.confirm_destructivebooleantrue for any op that drops data. Defaults to false.Returns
migration_idstringprior_versionintegernew_versionintegermigration_warningsarrayErrors
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)
Parameters
namestringdescriptionstringagent_owner_instructionsstringnull to clear it.expected_owner_instructions_epochintegeragent_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_surfacesarrayclaude_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_tierstringautoload or available. Advisory.agent_engagement_hintsarrayReturns
idstringnamestringdescriptionstringReturns
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.
Errors
When expected_owner_instructions_epoch is sent and the stored instructions changed after it was read, the save is refused with 409 and INVALID_INPUT rather than discarding the other edit. See Structured errors.
PATCH/instances/{instance_id}Update some instance metadata fields, leaving the rest alone
Parameters
namestringdescriptionstringnull to clear).agent_surfacesarrayclaude_code, codex, claude_desktop, chatgpt. Advisory — it orders the connect steps an instance is offered and grants no access.agent_default_binding_tierstringautoload or available. Advisory.agent_engagement_hintsarrayagent_owner_instructionsstringPUT for this field — it is the only route that accepts expected_owner_instructions_epoch, so an edit sent here cannot be guarded against a concurrent one.Returns
idstringnamestringdescriptionstringReturns
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.
Errors
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
Parameters
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".Returns
instance_idstringinstance_namestringname beside it; do not compare or join the two.install_page_urlstringsurfacesarray{ 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_agentstringformatstring200, so nothing else tells a caller who asked for project whether the instance has nothing committable or the request was never understood.projectobjectnull unless format=project was both requested and honoured. { fragments, manual_steps }; each fragment is { path, purpose, merge, content }. merge ("merge_json" / "merge_toml") means merge into whatever the repository already has — never overwrite, never append: applying a fragment twice must leave the file as one application would. manual_steps covers surfaces with no committable channel.Details
Answers "how do I also reach this memory somewhere else" — a desktop client, another
editor, a teammate's machine. The same payload the create response carries, the
get_setup_instructions MCP tools serve and xmemcli instance setup prints. Computed
from the instance's current metadata, so editing its surface hints changes the next
response.
Carries no credential. The steps tell a reader to sign in through the CLI, which puts the key on disk out of band precisely so that it stays out of a transcript.
In the SDKs as get_setup_instructions() / getSetupInstructions(), and on an instance
handle as setup_instructions() / setupInstructions() without the id.
DELETE/instances/{instance_id}Delete an instance
Returns
A list of deleted instance IDs.
Schema evolution
Section titled “Schema evolution”xmemory can change a live schema without losing data. There are two paths, both ending in an atomic migration with an audit record: a direct migration you drive yourself, and a suggestion engine that proposes improvements from real read traffic. Additive-only callers are unaffected — the endpoints above keep working as before.
Endpoints
POST/instances/{instance_id}/migrations/dry_runPreview a migration without applying it
Parameters
instance_schemaobjectrequiredmigration_planobjectgenerateSchema.confirm_destructivebooleantrue for any op that drops data. Defaults to false.Returns
statementsarraycurrent_versionintegerplan_summaryobject{ count_by_op_type, total }.requires_metadata_syncbooleanExample response
{ "status": "ok", "instance_id": "...", "current_version": 4, "statements": ["ALTER TABLE person RENAME COLUMN mail TO email"], "warnings": [], "plan_summary": { "count_by_op_type": { "rename_field": 1 }, "total": 1 }, "requires_metadata_sync": true}Errors
A non-additive change without a plan returns non_additive_change_requires_plan; a data-dropping op without confirmation returns destructive_confirmation_required. See Structured errors.
GET/instances/{instance_id}/migrationsList applied migrations, newest first
Parameters
limitinteger50.before_idstringinclude_yamlbooleanfalse.Returns
itemsarraygetMigration for the per-item shape). source is direct or suggestion_engine.next_before_idstringbefore_id to fetch the next (older) page; null on the last page.has_morebooleanExample response
{ "status": "ok", "items": [ { "id": "...", "applied_at": "...", "source": "suggestion_engine", "decided_by": "...", "prior_version": 3, "new_version": 4, "ops": [ ], "ops_summary": { } } ], "next_before_id": null, "has_more": false}GET/instances/{instance_id}/migrations/{migration_id}Get a single migration record
Parameters
include_yamlbooleanyaml_before / yaml_after). Defaults to false.Returns
idstringapplied_atstringsourcestringdirect or suggestion_engine.prior_versionintegernew_versionintegeropsarrayops_summaryobjectyaml_beforestringinclude_yaml: true).yaml_afterstringinclude_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
Parameters
session_idstring{} or { "session_id": "..." }.Returns
proposalobjectproposal_version (concurrency token), schema_version, and items.Example response
{ "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"] } ] }}Errors
When a migration is already running, the response is { "status": "evolution_in_progress", "retry_after_seconds": 5 } — back off and retry.
POST/instances/{instance_id}/suggestions/decideRecord an accept / reject / defer per item in one batch
Parameters
proposal_versionstringrequiredproposal_version from review.decisionsarrayrequired{ "item_fingerprint": "...", "decision": "accept" | "reject" | "defer" } per item.session_idstringReturns
The recorded decisions, advisory dependency warnings, and a next_proposal_version you can pass straight to apply.
Example response
{ "proposal_version": "ab12...", "decisions": [ { "item_fingerprint": "fp1", "decision": "accept" } ]}Errors
A proposal_version that no longer matches the current proposal returns stale_proposal_version; malformed decisions return invalid_decision_input. See Structured errors.
POST/instances/{instance_id}/suggestions/applyCommit accepted decisions as one migration
Parameters
proposal_versionstringrequirednext_proposal_version from decide.session_idstringReturns
On success, { "status": "ok", "migration_id": "...", "prior_version": 4, "new_version": 5, "applied_items": ["fp1"], "summary": "..." }, or { "status": "nothing_to_apply" } if no accepted items remained.
Example response
{ "status": "ok", "migration_id": "...", "prior_version": 4, "new_version": 5, "applied_items": ["fp1"], "summary": "..." }Errors
A stale token returns stale_proposal_version; an unmet dependency returns dependency_closure_failed; a failed commit returns apply_failed. See Structured errors.
POST/instances/{instance_id}/pending-feedback/discardClear the pending schema-improvement backlog for this instance
Returns
rows_discardedintegerinstance_idstringExample response
{ "status": "ok", "instance_id": "...", "rows_discarded": 12 }Details
Bulk-supersedes the pending schema-improvement backlog. Idempotent — discarded signals never resurface, and calling it with nothing pending is a safe no-op.
Structured errors
Section titled “Structured errors”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.
Limits
Section titled “Limits”No backfill of historical data into new fields; no rollback of a committed migration (atomic abort on failure is the safety story); identity tightening (change_object.new_primary_key, change_relation.new_keys) is relax-only.
Health check
Section titled “Health check”Endpoints
GET/healthzReturns 200 if the API is reachable (no authentication required)
Example request
curl https://api.xmemory.ai/healthzErrors
Section titled “Errors”All endpoints return an error response on failure with the appropriate HTTP status code (400, 401, 402, 403, 404, 429, 500, 503).
Error bodies carry a structured code in errors[0]. Branch on the code, not on the bare HTTP status — the status alone does not say whether retrying is worthwhile.
Quota and rate limits
Section titled “Quota and rate limits”| HTTP | code |
Meaning | Retryable? |
|---|---|---|---|
402 |
QUOTA_EXCEEDED |
The tenant exhausted its plan’s usage allowance (daily or monthly token quota). | No — only after the quota window resets. |
429 |
RATE_LIMITED |
A genuine velocity/rate limit was hit (too many requests in a short window). | Yes — back off and retry, honoring Retry-After. |
Transient server-side failures
Section titled “Transient server-side failures”| 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. |
503 — SERVICE_UNAVAILABLE
Section titled “503 — SERVICE_UNAVAILABLE”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.
402 — QUOTA_EXCEEDED
Section titled “402 — QUOTA_EXCEEDED”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.kindis"daily_quota_exceeded"or"monthly_quota_exceeded".details.retry_after_secondsis the number of seconds until the window resets, ornullif the window is not resettable.- When the window is resettable, the server also sends an HTTP
Retry-After: <seconds>header.
This is not retryable in the short term. Surface a “usage limit reached — upgrade your plan” message to the user. Do not auto-retry; you may retry once the quota window has reset.
429 — RATE_LIMITED
Section titled “429 — RATE_LIMITED”A real rate limit — the client is sending requests too fast. This is retryable: back off and retry, honoring the HTTP Retry-After: <seconds> header when present. The official SDKs parse that header into a convenience attribute — .retry_after (Python, int seconds or None) / .retryAfter (TypeScript, number seconds or undefined) — on the raised error, for both this case and a resettable 402 QUOTA_EXCEEDED.