API Reference
Base URL: https://core.mnemoverse.com/api/v1
This is the REST base (auth via X-Api-Key) — it is not an MCP endpoint. MCP connectors for Claude / ChatGPT / editors use https://mcp.mnemoverse.com/mcp instead — see Agent Setup.
All /memory/* endpoints require authentication via X-Api-Key header or Authorization: Bearer header. Health endpoints (/health, /health/ready) are public and unauthenticated. Internal administrative endpoints exist but use separate authentication and are not part of the public API.
Authentication
Include your API key in every request:
# Option 1: X-Api-Key header (recommended)
curl -H "X-Api-Key: mk_live_YOUR_KEY" ...
# Option 2: Bearer token
curl -H "Authorization: Bearer mk_live_YOUR_KEY" ...Endpoints
POST /memory/write
Store a single memory atom.
Request:
| Field | Type | Required | Description |
|---|---|---|---|
content | string | Yes | The insight, pattern, or lesson to remember (1-10,000 chars) |
concepts | string[] | No | Key concepts for Hebbian associations (up to 256 items) |
domain | string | No | Namespace, up to 100 characters: general, user:X, project:Z (default: general) |
metadata | object | No | Arbitrary key-value metadata; serialized UTF-8 JSON must be at most 65,536 bytes |
fingerprint | string | No | Optional exact-match fingerprint (max 512 characters) |
external_ref | string | No | Client-provided unique reference for idempotent writes. May not start with secret:, the prefix reserved for Vault secret aliases: see "Reserved external_ref prefix" below |
supersedes | string[] | No | Atom IDs, in your own organization, that this write replaces (up to 32 items). See "Supersede semantics" below |
Response:
| Field | Type | Description |
|---|---|---|
stored | boolean | True if the write passed the write gate (novelty check) |
atom_id | UUID | UUID of the stored atom, or null if filtered |
importance | float | Novelty score [0, 1]: how different the write is from the nearest existing memory in the same domain |
reason | string | Why stored or filtered |
superseded | UUID[] | IDs of atoms this write marked as superseded. Always present; [] when supersedes was omitted or empty |
Reserved external_ref prefix: an external_ref that starts with secret: (exact and case-sensitive, so Secret:x and crm:secret:1 are ordinary refs) is refused with HTTP 400 before anything is stored, and the answer is the same whether or not a secret with that alias exists:
{
"code": "VALIDATION_ERROR",
"message": "Request validation failed",
"requestId": "req_abc123",
"retryable": false,
"details": {
"errors": [
{
"loc": ["body", "external_ref"],
"msg": "Value error, external_ref may not start with 'secret:', which is reserved for Vault secret aliases",
"type": "value_error"
}
]
}
}A memory stored under such a ref before 2026-10-02 keeps it and still resolves by it.
Supersede semantics: pass supersedes to mark this write as the correction for one or more earlier atoms. All targets and the new atom are committed in a single transaction: if any target fails validation, the write rolls back entirely and none of the targets are touched. A target is not deleted; it gains a superseded_by pointer to the new atom and stays fetchable by ID (see GET /memory/atoms/{atom_id} below). supersedes links atoms, not domains, so the new atom can live in a different domain than what it replaces.
Errors specific to supersedes:
| Case | Response |
|---|---|
| A target ID is malformed, repeated in the list, or the list has more than 32 items | 400 VALIDATION_ERROR |
This write's own domain is xroom:* and supersedes is non-empty | 400 VALIDATION_ERROR |
| A target does not exist in your organization | 404 NOT_FOUND (a foreign-org atom and a nonexistent one return the identical 404, deliberately: an anti-enumeration measure, not a bug) |
| A target already has a successor | 409 CONFLICT, naming that target's immediate successor's ID (not the head of a longer chain) |
| A target was claimed by a concurrent write between your read and this write | 409 CONFLICT: re-read that revision and supersede it instead |
| A target is a secret atom (Vault-owned lifecycle) | 409 CONFLICT |
With multiple targets, validation runs in list order and stops at the first failure: the whole write rolls back, nothing partial is stored, and no target is touched.
Read-side filtering is off by default and switchable per organisation (as of 2026-09-16)
The filter that hides a replaced revision from default reads is off by default. An organisation switches it on in the console, under Settings → "Hide replaced memories". For an organisation that has not switched it on, POST /memory/read, /memory/recent, /memory/query, and batch fetches return superseded revisions exactly as before — so supersedes does not make a stale fact stop showing up in that organisation's search results. It records the correction; it does not hide the original unless the organisation turns the setting on.
True either way, on or off: a replaced revision carries superseded_by, the revision that replaced it carries supersedes, read items report both fields regardless of the setting, and GET /memory/atoms/{atom_id}/chain walks the chain from any revision in it, within its lookup budget and reporting any break rather than failing.
For an organisation that has switched the setting on, search and feed reads return live tips only. History stays reachable by ID, by external_ref, through include_history on a read, and through the chain endpoint — the setting never applies to the two point lookups. Superseded rows still count toward total_atoms in GET /memory/stats either way.
Nothing about what a client sends changes when an organisation flips its setting: supersedes / superseded_by are reported either way and include_history is already accepted, so no client code needs rewriting. What comes back does change for that organisation — that is the point of the switch — and Reading history lists the behaviours that move with it.
POST /memory/write-batch
Store up to 500 atoms in one request.
An item whose external_ref starts with secret: fails the whole request with 400 VALIDATION_ERROR and nothing in the batch is written; the error's loc is ["body", "items", <index>, "external_ref"] (see "Reserved external_ref prefix" under POST /memory/write).
supersedes is not accepted on either batch route: an item carrying it fails the whole request with 422 VALIDATION_ERROR. Per-item transactional semantics have no honest answer in a batch — if item 3 supersedes an atom and item 5 fails, "what did the batch do" has no true and useful answer — so supersede-writes go one at a time to POST /memory/write, where each gets its own transaction.
Request:
| Field | Type | Required | Description |
|---|---|---|---|
items | WriteRequest[] | Yes | Array of write requests (1-500) |
Response:
| Field | Type | Description |
|---|---|---|
total_count | int | Total atoms processed |
stored_count | int | Atoms that passed importance gate |
results | object[] | Per-atom results with index, stored, atom_id, importance, reason, and error. reason explains the gate decision when available |
POST /memory/write_batch
Use the hyphenated /memory/write-batch instead
Both spellings are served, but they are not the same endpoint — they are two handlers with different behaviour, and the difference is one you will only notice when something has already gone wrong.
/memory/write-batch supports the Idempotency-Key header: if the network drops after the server processed your batch, retrying with the same key returns the cached response instead of writing everything twice.
/memory/write_batch has no idempotency. A retry after a timeout writes the batch again — which is exactly the duplicate-write scenario the key exists to prevent.
The underscore form is kept only for callers already written against it.
Otherwise identical to /memory/write-batch: the same request and response shapes, the same per-item tenant resolution.
POST /memory/read
Query memory with semantic search + Hebbian expansion.
Request:
| Field | Type | Required | Description |
|---|---|---|---|
query | string | Yes | Natural language query (1-5,000 chars) |
top_k | int | No | Requested number of results (1-500, default: 10). Not a hard cap: association expansion can return more, and the relevance floor can return fewer |
domain | string | No | Filter by domain (null = all) |
min_relevance | float | No | Minimum relevance threshold (0-1, default: 0.3) |
include_associations | bool | No | Expand via Hebbian associations (default: true) |
include_history | bool | No | Return superseded revisions alongside live ones (default: false). Grants eligibility under the normal ranking, not chain completeness — see Reading history |
diversity | float | No | 0 to 1 (default 0: the ranked results are cut to top_k). Above 0 the cut becomes a diversity selection: a near-copy of an item already picked gives its slot to the next distinct one. Applies only when top_k is below 200 |
concepts | string[] | No | Concept hints to bias search |
author_principal | string | No | Rooms: filter by authoring principal (authored atoms) |
author_agent | string | No | Rooms: filter by authoring agent |
author_client_env | string | No | Rooms: filter by authoring client environment |
author_is_external | bool | No | Rooms: only atoms from external (non-owner) members |
Response:
| Field | Type | Description |
|---|---|---|
items | MemoryItem[] | Matching memories, ordered by relevance |
episodic_hit | boolean | True if exact fingerprint match found |
query_concepts | string[] | Concepts extracted from query |
expanded_concepts | string[] | Concepts after Hebbian expansion |
search_time_ms | float | Search duration in milliseconds |
MemoryItem fields:
| Field | Type | Description |
|---|---|---|
atom_id | UUID | Unique identifier |
content | string | Stored text content |
relevance | float | Final score after valence modulation: score * (1 + alpha * v). score is the item's relevance score before this term (an internal ranking value, not necessarily equal to the raw-cosine similarity field below). v is the item's valence from outcome feedback. alpha is a coefficient set by the service and not exposed by the API |
similarity | float | Raw cosine similarity |
valence | float | Outcome polarity [-1, +1] |
importance | float | Importance score [0, 1] |
source | string | Which retrieval stage found the item. In production reads: semantic (similarity to the query), concept_scoped (found under a concept extracted from the query), hebbian_retrieval (missed by the direct stages and reached through learned associations, via a concept the query did not name). episodic marks an exact fingerprint match. Other stage names can appear: treat an unknown value as one more stage. |
concepts | string[] | Associated concepts |
domain | string | Domain namespace |
metadata | object | Arbitrary metadata |
provenance | object | Authorship record — present on authored (Rooms) atoms |
supersedes | UUID[] | Revisions this item replaced. Omitted when it replaced nothing |
superseded_by | UUID | The revision that replaced this one. Omitted while this item is the live tip; present means this item is history. Reported as a fact of the data regardless of the read-side setting — while filtering is off, this field is how you tell a replaced revision from a live one |
Reading history
include_history is accepted by POST /memory/read, /memory/read-batch (per query), /memory/recent and /memory/query. It asks for superseded revisions alongside live ones, and a few things are worth knowing before you rely on it:
- Eligibility, not completeness. History competes under the same ranking,
min_relevanceandtop_k/limitas everything else, so a read can legitimately return part of a chain and no read promises all of it. For the whole chain, useGET /memory/atoms/{atom_id}/chain. - History is returned where it is stored. A revision keeps its own domain, so a read scoped to the successor's domain does not surface a predecessor that lives elsewhere. Use an unscoped read,
GET /memory/atoms/{atom_id}, or the chain endpoint. - One exception to that domain rule, and it is on the
fingerprintchannel. Not on query text: when a read carries the optionalfingerprintfield and it resolves to a revision that has since been replaced, the live tip is pulled in alongside it even if that tip lives in another domain — the fingerprint index is keyed by tenant and hash alone, so it is domain-blind. Both are seated ahead of the ranked hits, which fill the remainingtop_kslots. Two caveats: this fires once read-side filtering is on and does not wait forinclude_history, so a caller who never asked for history can receive a replaced revision this way; and the tip is best-effort — if the chain above the matched revision is broken or runs deeper than 32 links, you get the matched revision alone, and itssuperseded_bystill tells you it is history. - History is returned, not reinforced. Once filtering is on, a superseded revision that comes back through
include_historyis not counted as a use: itsaccess_countandlast_accessed_atdo not move, and it forms no new associations. Live items in the same response are unaffected. - You cannot feature-detect it. The read endpoints ignore request fields they do not recognise rather than rejecting them, so sending
include_historyto a deployment that predates it returns200with the field silently dropped, never a422. No response field reports whether the server-side filter is on, either. - No effect for organisations that have not switched read-side filtering on (nothing is hidden, so nothing needs unhiding), and inert for
xroom:*domains, whose atoms cannot be superseded at all. Rooms are a/memory/readand/memory/recentfeature:POST /memory/read-batchand/memory/queryrefuse anxroom:domain with400 VALIDATION_ERRORbefore any of this applies.
Diverse results
diversity is accepted by POST /memory/read, /memory/read-batch (per query) and /memory/query. At 0, the default, nothing changes: the ranked results are cut to top_k. Above 0, the cut becomes a maximal marginal relevance selection: items are picked one at a time, each the one that best trades its relevance against its cosine similarity to the items already picked, with weight diversity on the similarity and 1 - diversity on relevance. A near-copy of something already in the answer loses its slot to the next distinct item; at 1, similarity alone decides after the first pick.
- It selects, it does not re-score. Relevance values in the response are unchanged; only which items fill the
top_kslots changes. The best-ranked item always keeps first place. - It looks at up to 200 candidates: the ranked results that cleared
min_relevance, after the endpoint's own ranking (on/memory/read, outcome feedback and recency included). It applies only when there are more candidates thantop_kandtop_kis below 200; otherwise the plain cut is returned. - Pinned and reserved slots stay. Revisions pinned by a
fingerprintand the association-reserved slots are seated as before. - Timings. With
include_timings, a read that ran the selection reportsdiversity_ms, and the vector load counts inn_db_roundtrips. - Values outside 0 to 1 are a
400validation error. A deployment that predates the field ignores it, like any unknown field.
POST /memory/graph
Bounded read of the concept-association graph around seeds. Distinct from /memory/read's expanded_concepts (names only, discarded weights), this returns the actual Hebbian-edge rows — weight, valence, count — for a caller-supplied neighbourhood, never an organization's whole graph. Same org resolution and auth/scope as /memory/read — a read-only operation (require_write=False): the same API key that can call /memory/read can call this endpoint, with no separate permission to grant.
An unknown seed, or one whose edges all fall below min_weight, simply contributes nothing: an all-unknown seeds list is a 200 empty graph, not a 404 — the same "absence is not an error" contract as /memory/recent.
Request:
| Field | Type | Required | Description |
|---|---|---|---|
seeds | string[] | Yes | Concept names to center the graph on (1-20 items) |
depth | int | No | Hop count from the seeds (1-3, default: 1). depth=1 returns only edges touching a seed directly; depth=2/3 walk one further hop per level. Each hop is independently bounded server-side by a per-hop row cap (see truncated) |
domain | string | No | Same mechanism as /memory/read, not a filter over one org's edges: an xroom:<room_id> value resolves (after the membership check) to that room's own storage bucket, so the graph is read from the room's edges instead of the caller's own. Any other value (or null) has no effect |
min_weight | float | No | Drop edges below this weight (>= 0). null = no caller floor at depth=1; at depth >= 2 the server applies its own floor of 0.05 at every hop, including hop 0, when this is left null — reported back as min_weight_applied. An explicit value, including 0.0, is honoured at every hop with no server override |
limit | int | No | Maximum number of edges to return (1-500, default: 100) |
A seed is matched against hebbian_edges.source_concept / target_concept, which are VARCHAR(200) columns server-side; a seed longer than 200 characters, or one that is blank, is rejected with 400 VALIDATION_ERROR before any query runs, and so is a non-finite min_weight (NaN, Infinity). These checks are request validators and do not appear in the published OpenAPI schema, which only carries the list bounds.
Response:
| Field | Type | Description |
|---|---|---|
nodes | GraphNode[] | Concepts touched by edges below. A seed with no surviving edge (unknown concept, or every edge fell below min_weight) is not listed — nodes are derived from the returned edges, not echoed from the request |
edges | GraphEdge[] | Association edges found within depth hops |
truncated | boolean | True when either a per-hop server-side cap or the request's limit cut the walk short — the graph may hold more edges than are reported here |
min_weight_applied | float | The weight floor actually used at every hop of the walk. Equal to the request's min_weight when it was set (an explicit value, including 0.0, is never overridden); equal to the server floor 0.05 when the request left min_weight null and the requested depth was >= 2; equal to 0.0 when it was null and depth == 1 |
GraphNode fields:
| Field | Type | Description |
|---|---|---|
concept | string | The concept name |
degree | int | Number of returned edges touching this concept — a property of this response's edge set, not the concept's total degree across the org's whole graph (which limit/truncated may cut short) |
GraphEdge fields:
| Field | Type | Description |
|---|---|---|
source | string | Source concept (storage order, not learn order) |
target | string | Target concept (storage order, not learn order) |
weight | float | Co-activation strength, [0, max_weight] |
valence | float | Outcome polarity, [-1, +1] |
count | int | Co-activation count |
updated_at | string | UTC instant this edge was last reinforced |
Edges are undirected by construction: storage dedupes each concept pair to a single row (min(a,b), max(a,b)), so there is only ever one edge per pair to report — source/target reflect storage order, not which concept was learned first, and no direction is implied.
Example — two-hop walk:
Request:
{
"seeds": ["rotation", "symmetry"],
"depth": 2
}Response:
{
"nodes": [
{ "concept": "rotation", "degree": 1 },
{ "concept": "symmetry", "degree": 2 },
{ "concept": "group_theory", "degree": 1 }
],
"edges": [
{ "source": "rotation", "target": "symmetry", "weight": 0.8, "valence": 0.1, "count": 3, "updated_at": "2026-09-24T00:00:00Z" },
{ "source": "symmetry", "target": "group_theory", "weight": 0.42, "valence": 0.05, "count": 2, "updated_at": "2026-09-23T00:00:00Z" }
],
"truncated": false,
"min_weight_applied": 0.05
}min_weight was omitted and the requested depth (2) is >= 2, so the server floored every hop at 0.05 and reported that back as min_weight_applied. group_theory was reached one hop past symmetry; it was never a seed, so it appears in nodes but not in the request's seeds.
Errors: the bounds above (seeds, depth, limit, min_weight) are schema-level constraints, so a value outside them returns 400 VALIDATION_ERROR before the route handler runs. Otherwise the standard error responses apply, including 401/403 for a missing or out-of-scope key.
Also from the SDK and MCP. The Python SDK's graph() has wrapped this endpoint since 0.3.1, and the MCP tool memory_graph since package 0.13.0, on the local server and the hosted connector alike.
POST /memory/read-batch
Batch query up to 50 queries in one request. include_history and diversity are fields of each query, not of the batch: one batch can ask for history on one question and the current answer on the next.
Request:
| Field | Type | Required | Description |
|---|---|---|---|
queries | ReadRequest[] | Yes | Array of read requests (1-50) |
Response:
| Field | Type | Description |
|---|---|---|
results | ReadResponse[] | Per-query results |
POST /memory/recent
The queryless feed: the newest entries first, with no search. Where /memory/read answers "what do I know about X" and returns what matches, this answers "what happened lately" and is complete by construction — nothing is skipped because nothing is ranked away. One thing can be filtered rather than ranked away: for an organisation that has switched read-side filtering on, superseded revisions leave the feed unless you pass include_history.
Paged by cursor rather than truncated. When more entries exist the response carries next_cursor; passing it back continues the listing with no skips and no duplicates, which LIMIT/OFFSET cannot guarantee while writes are landing.
Request:
| Field | Type | Required | Description |
|---|---|---|---|
domain | string | No | Restrict to one domain (null = all your domains). An xroom:<room_id> address resolves through the same membership check as /memory/read |
since | string | No | Only entries created at or after this ISO-8601 instant, inclusive. Naive datetimes are read as UTC |
until | string | No | Only entries created at or before this instant, inclusive. Pair with since for a closed window |
exclude_author | string | No | Drop entries written by this author principal — the "everyone but me" read in a shared room |
include_history | bool | No | Include superseded revisions in the feed (default: false) — see Reading history |
limit | integer | No | Page size, 1–100 (default 20) |
cursor | string | No | Continuation cursor from a previous response's next_cursor |
Response:
| Field | Type | Description |
|---|---|---|
items | RecentItem[] | Newest first. Each carries atom_id, content, domain, created_at, concepts, and provenance when authorship was recorded, plus supersedes when the entry replaced something and superseded_by when something replaced it, each key omitted otherwise |
next_cursor | string | null | Present when older entries remain; null at the end of the feed |
An empty feed is 200 with items: [] — never a 404.
Secrets are excluded from this feed by design. A queryless listing would make every secret's alias enumerable on each first page; they have their own surface at GET /vault/secrets.
POST /memory/query
Advanced query with multi-domain and metadata filtering.
Request:
| Field | Type | Required | Description |
|---|---|---|---|
query | string | Yes | Natural language query |
domains | string[] | No | Filter by multiple domains |
metadata_filter | Filter[] | No | JSONB metadata conditions (eq, contains, in) |
top_k | int | No | Requested number of results (default: 10). Not a hard cap: association expansion can return more, and the relevance floor can return fewer |
min_relevance | float | No | Min relevance (default: 0.3) |
include_associations | bool | No | Hebbian expansion (default: true) |
include_history | bool | No | Return superseded revisions alongside live ones (default: false) — see Reading history |
diversity | float | No | 0 to 1 (default 0: the ranked results are cut to top_k). Above 0 the cut becomes a diversity selection: a near-copy of an item already picked gives its slot to the next distinct one. Applies only when top_k is below 200 |
concepts | string[] | No | Concept hints |
POST /memory/feedback
Report outcome (success/failure) for memories. Updates valence and Hebbian associations.
Request:
| Field | Type | Required | Description |
|---|---|---|---|
atom_ids | UUID[] | Yes | Atoms to update |
outcome | float | Yes | Outcome signal: -1.0 (failure) to +1.0 (success) |
concepts | string[] | No | Concepts to reinforce |
query_concepts | string[] | No | Original query concepts (enables co-activation learning) |
domain | string | No | Domain for the feedback |
Response:
| Field | Type | Description |
|---|---|---|
updated_count | int | Number of atoms updated |
avg_valence | float | Average valence of the rated memories after the update; 0 while the update is applied in the background. The next read shows each memory's new valence. |
coactivation_edges | int | Hebbian edges created/updated |
POST /memory/consolidate
Not enabled
Consolidation is off on the hosted service and we do not currently recommend turning it on. The endpoint responds 503 with a typed reason rather than consolidating. Nothing about your memories changes; the rest of the API is unaffected.
Consolidation groups similar memories under a prototype. Two things keep it off. It has never once completed on the hosted service — every run in the retained window failed, silently, because the failure was swallowed by a background path. And a prototype competes with its own sources for the same result slots, so a summary can crowd out the specific memory you were looking for. Neither of those is something to enable and find out about later, and no customer is close to needing the compression. The design we intend to build instead adds the summary as a layer over memories that stay addressable.
There is also no capacity pressure behind it: no tenant is near its storage limit, so nothing is waiting on compression.
Request:
| Field | Type | Required | Description |
|---|---|---|---|
domain | string | No | Domain to consolidate (null = general) |
Response: while consolidation is disabled the endpoint returns 503 with a reason field. The success shape below is documented for self-hosted deployments (Enterprise, by agreement), where an operator may enable it deliberately.
| Field | Type | Description |
|---|---|---|
domain | string | Domain consolidated |
atoms_before | int | Memories in the domain before the run. Vault secrets are not counted |
atoms_after | int | Memories after the run, with each group of sources merged into a prototype in this run counted once, as that prototype. With soft consolidation (the default) the merged sources stay stored, so this is not the number of rows left in the domain. Vault secrets are not counted |
prototypes_created | int | New prototype atoms |
singletons_protected | int | Von Restorff protected atoms |
compression_ratio | float | atoms_after / atoms_before, from 0 to 1 |
duration_ms | float | Duration in milliseconds |
Vault secrets in the domain are never consolidated and stay as they are. A domain that holds only secrets answers exactly as an empty domain does.
GET /memory/stats
Get memory statistics for your tenant.
Response:
| Field | Type | Description |
|---|---|---|
total_atoms | int | Memories stored — every revision, replaced ones included; Vault secrets are not counted. This is the number your plan's atom limit is measured against, so a supersession chain of five revisions counts as five |
episodes | int | Episode-type atoms |
prototypes | int | Prototype atoms (from consolidation) |
singletons | int | Protected singleton atoms |
hebbian_edges | int | COUNT(*) of stored association rows for the tenant. Mixes within-atom pairs (re-derivable from atom content) and cross-atom learned associations indiscriminately, with no dedup: a weak health signal. Don't use it to judge learning progress. |
episodic_fingerprints | int | Number of distinct episodic-content fingerprints across stored atoms |
domains | string[] | Active domain names. A domain that holds only Vault secrets (by default secrets, where POST /vault/secrets stores them) is not listed |
avg_valence | float | Average outcome valence of your memories, Vault secrets excluded |
avg_importance | float | Average importance score of your memories, Vault secrets excluded |
GET /memory/atoms/
Fetch a single atom by ID. Returns the full atom envelope (content, concepts, importance, valence, domain, created_at, etc.). Returns 404 NOT_FOUND if the atom does not exist in your tenant. A Vault secret's ID (the atom_id that POST /vault/secrets returns) answers the same 404, with the same body as an ID that names no atom; secrets are listed through GET /vault/secrets.
The envelope also carries this atom's own links in the supersession chain: supersedes (UUID[], the atoms this one replaces) and superseded_by (UUID or null, the atom that replaced this one, if any). This entry point always returns a superseded atom in full, whatever the read-side setting does. For the rest of the chain — everything reachable from this revision in both directions — use GET /memory/atoms/{atom_id}/chain.
GET /memory/atoms/{atom_id}/chain
Every revision reachable from one atom by supersession links. Hand it any revision — the oldest, the newest, one in the middle — and get the whole chain: the walk goes up through superseded_by to the live tip and down through supersedes, which is a list, because one correction can consolidate several predecessors. Starting from any one of several predecessors a single correction consolidated therefore still reveals the others.
include_history on a read grants eligibility under ranking and never promises a complete chain. This is the endpoint that does. It resolves revisions by ID, so a chain is available whether or not read-side filtering is enabled: the chain is a fact about the data, not a behaviour of the filter.
Response:
| Field | Type | Description |
|---|---|---|
atom_id | UUID | The revision the walk started from |
revisions | ChainRevision[] | Every revision found, in topological order along the supersedes links: a predecessor never follows something that replaced it. created_at decides only between branches the links leave genuinely unordered. Find the current revision by the is_head flag, never by position — a truncated or broken chain has no head to end on |
dangling | UUID[] | Chain pointers that resolved to no revision — the revision they name was destroyed by a delete (DELETE /memory/atoms/{atom_id}, or a DELETE /memory/domain/{domain} wipe that took a revision stored in that domain), belongs to another tenant, or is a Vault secret, which the chain never returns as a revision. Breaks in a chain are reported here, not raised as errors: you already hold these IDs, they came off rows you can read |
truncated | boolean | true when the walk spent its lookup budget (64 lookups per call) and stopped early, so revisions is part of the chain rather than all of it. The budget counts every lookup, dangling misses included, so a badly broken chain can set this with far fewer than 64 revisions returned — read it as "the walk stopped early", never as a chain length. There is no continuation parameter: the walk always spreads outward from the ID in the path, so to reach the far end of a long chain, call again with an ID from the edge of the revisions you got back |
ChainRevision carries id, content, domain (revisions of one chain may live in different domains), concepts, created_at and is_head, plus supersedes and superseded_by under the same omission rule read items follow: the key is absent, not null, when there is nothing to report. In a chain that rule always bites — the head has no superseded_by, the oldest revision has no supersedes — so parse both as optional.
is_head is true for the live tip, the revision nothing has replaced. It is false for every historical revision, and also for a revision whose successor was destroyed (that successor's ID then appears in dangling) — a broken chain has no head, and naming one would rewrite history.
An unknown atom ID, one belonging to another tenant, and a Vault secret's ID all return the same 404 NOT_FOUND, deliberately indistinguishable, the same anti-probing rule GET /memory/atoms/{atom_id} follows. An atom that lives in a shared room answers 404 here too: like every by-ID surface, this one resolves against your own account rather than through room membership.
DELETE /memory/atoms/
Destructive administrative operation, not a normal step in the memory lifecycle
Deletion is a destructive, administrative/service-level operation. It is not recommended as part of routine memory maintenance. If a stored fact is wrong or stale, prefer writing a corrected memory with supersedes over deleting the old one: it records the correction and keeps the old revision auditable instead of destroying it outright. Reach for this endpoint for genuine cleanup (secret exposure, legal takedown, tenant offboarding), not as an "update" mechanism.
Supersede instead of delete
The write side of supersession is live: pass supersedes on POST /memory/write to mark a new atom as the correction for one or more old ones. The old atoms are not deleted; they gain a superseded_by pointer and stay fetchable by ID. The read-side filter that hides a replaced revision from default search results is off by default and switchable per organisation in the console (as of 2026-09-16); the callout under POST /memory/write states the current behaviour, so for an organisation that has not switched it on supersedes records the correction rather than hiding the original. Either way the chain itself is readable from any revision in it through GET /memory/atoms/{atom_id}/chain.
One cost to plan for: a superseded revision is still a stored row. total_atoms in GET /memory/stats counts every revision, replaced ones included, and that same total is what your plan's atom limit is measured against — so a long correction history consumes quota even after read-side filtering starts hiding it from search.
Permanently delete one atom. Idempotent: deleting an already-gone atom returns 200 with {"deleted": 0, "atom_id": "..."} rather than 404 — so retry loops are safe.
Atoms written through the Vault (secrets) are immutable via this endpoint: deleting a secret atom returns 403 FORBIDDEN. Manage secrets through the Vault surface instead.
Deleting an atom removes only that atom's row: it does not touch the tenant's learned Hebbian associations. Earlier builds ran an undocumented post-delete sweep across the tenant's whole edge set: any edge without a remaining atom carrying both its endpoint concepts together was deleted, a rule that preserved within-atom pairs (re-derivable from atom content) but destroyed cross-atom learned associations regardless of whether they involved the deleted atom at all. That sweep has been removed. As of this fix, this endpoint deletes the requested atom and nothing else.
The response always carries two more fields, empty and false when the deleted atom was not in a chain:
| Field | Type | Description |
|---|---|---|
superseded_predecessors_left_hidden | UUID[] | Predecessors this atom superseded that now have no live successor, so they stay hidden from default reads once read-side filtering is enabled, though always fetchable by ID and through GET /memory/atoms/{atom_id}/chain. Empty unless this atom had its own supersedes chain |
history_destroyed | boolean | true when the deleted atom sat in a chain in either direction — it replaced something, or something replaced it. It can be true with the list above empty: deleting the oldest revision of a chain destroys a link without stranding any predecessor |
DELETE /memory/domain/
Destructive administrative operation
Same guidance as above, at domain scope: this is a bulk destructive operation, not a routine cleanup step. Prefer writing corrected memories, or using the /memory/feedback endpoint to down-rank stale entries, over wiping a domain.
Wipe every atom in a domain (e.g. project:old-client). Returns {"deleted": <int>, "domain": "...", "skipped_secrets": <int>, "orphan_edges_removed": 0}.
| Field | Type | Description |
|---|---|---|
deleted | int | Number of non-secret atoms deleted |
domain | string | The domain that was wiped |
skipped_secrets | int | Vault secret atoms in the domain that were not deleted (secrets are immune to this endpoint, same as DELETE /memory/atoms/{atom_id}) |
orphan_edges_removed | int | Deprecated, always 0. Earlier builds ran the same tenant-wide sweep after every domain wipe: any Hebbian edge without a remaining atom carrying both its endpoint concepts was deleted, wiping out cross-atom learned associations across the tenant regardless of whether they involved the wiped domain's atoms at all. That sweep has been removed. The field is kept for response-shape compatibility only. |
superseded_predecessors_left_hidden_count | int | Count of predecessors left permanently hidden from default reads because their successor lived in this domain and was wiped |
history_destroyed | boolean | true when the count above is non-zero |
Note: the REST route has no confirm-style interlock of its own — there is no MCP wrapper around it either, so no schema-level safety gate exists anywhere in the stack. Treat this endpoint as destructive on the client side.
DELETE /memory/reset
Single-tenant / self-hosted only, not for multi-tenant deployments
This is a global TRUNCATE across all tenants on the deployment, not a per-tenant wipe. It is disabled by default and refused at boot on any deployment running in a multi-tenant posture. It exists for single-tenant self-hosted deployments (Enterprise, by agreement) and development; never enable it on a shared deployment.
Gated server-side; returns 403 FORBIDDEN unless explicitly enabled on the deployment. On success, returns {"status": "cleared"}; there is no deleted-count field in the response.
GET /memory/domains
List active domain names in your tenant. Same data as the domains field of /memory/stats, but cheaper if you only need the list. A domain that holds only Vault secrets (by default secrets) is not listed.
POST /memory/atoms/by-external-ref
Look up a single atom by its external_ref (the optional client-supplied identifier set during write). Useful when the client wants to recover an atom ID after restart. Request body: { "external_ref": "<string, 1-255 chars>" }. Response: a single atom envelope (same shape as GET /memory/atoms/{atom_id}). Returns 404 NOT_FOUND if no atom matches. A ref that names a Vault secret (secret:<alias>) answers the same 404, with the same body as a ref that names nothing.
Like GET /memory/atoms/{atom_id}, this lookup ignores the read-side setting: it resolves a superseded revision in full, and the envelope carries supersedes / superseded_by.
GET /memory/export
Stream every atom in the calling tenant as JSONL (one atom JSON per line). Always tenant-scoped — there is no cross-tenant export and no domain filter; if you need a specific domain, post-filter the stream client-side. For backup / migration.
The stream carries every atom the tenant holds, superseded revisions included, whatever the read-side setting does, and each line carries supersedes and superseded_by — always, unlike read items: an unversioned atom exports as "supersedes": [] and "superseded_by": null rather than omitting the keys. An export of a versioned corpus therefore records which revision replaced which, instead of arriving as a bag of disconnected revisions. Note that there is no import endpoint, and a re-written export gets fresh atom IDs, so a chain has to be re-asserted write by write.
Each line also carries atom_type: episode, prototype or singleton for a memory, secret for a Vault secret. Your secrets are in the stream as rows with atom_type: "secret", their context (the purpose they were stored with) as content and their secret:<alias> ref as external_ref, never their value. Skip those rows when you re-write an export as memories: a memory write with a secret: ref answers 400 (see "Reserved external_ref prefix" under POST /memory/write).
Shared rooms (Beta)
A room is a memory pool shared across accounts. Addressed as a domain of the form xroom:<room_id>, so every memory endpoint that takes a domain works against a room you belong to — reads and writes go through a database-enforced membership check, not an application-layer one.
POST /memory/rooms
Create a room. The caller becomes its owner.
GET /memory/rooms
List the rooms you belong to — how a fresh session re-finds them.
POST /memory/rooms/{room_id}/invites
Mint an invite code for a room you own. Codes expire, and are single-use unless minted with max_uses (1 to 1000).
GET /memory/rooms/{room_id}/invites
List the outstanding invites for a room you own.
POST /memory/rooms/join
Redeem an invite code and join the room it belongs to.
Secrets
Values are sealed and never returned by any listing. These endpoints exist to discover which secrets are stored, by alias.
Search and the recent feed leave secrets out. Since 2026-10-02 a secret's atom ID or secret:<alias> ref answers 404 on the point reads and the chain exactly as one that names nothing, GET /memory/stats and GET /memory/domains neither count nor list them, they do not count toward the atom limit, and a memory write may not use a secret: ref. The memory routes still deal with a secret in these cases:
GET /memory/exportincludes secrets, as rows markedatom_type: "secret"without the value.DELETE /memory/atoms/{atom_id}answers403 FORBIDDENfor a secret's ID, andDELETE /memory/domain/{domain}skips secrets and reports how many inskipped_secrets.- A write whose
supersedesnames a secret answers409 CONFLICT.
Everything else on the memory surface neither returns nor counts a secret.
GET /vault/secrets
List stored secret aliases and their metadata. Never returns a value.
POST /vault/secrets
Store a secret under an alias.
GET /vault/secrets/by-alias/
Fetch one secret's metadata by alias. Never returns the value.
Health Endpoints
Two monitoring probes for load balancers, uptime services, and post-deploy smokes. Both are public — no authentication required — and are the only non-authed routes under /api/v1/. Hit them as often as your monitoring allows; cost is a single DB SELECT 1 on /health/ready, nothing on /health.
GET /health
Liveness probe. Returns 200 as long as the process is serving. Does NOT check the database, embedding model, or any downstream — a 200 here only means "uvicorn is accepting connections".
Response:
| Field | Type | Description |
|---|---|---|
status | string | Always "ok" when the process is up. |
database | boolean | Always false — this endpoint intentionally skips the DB round-trip. Use /health/ready for DB status. |
version | string | Service version (matches the deployed container). |
Example:
curl https://core.mnemoverse.com/api/v1/health
# {"status":"ok","database":false,"version":"1.0.0"}When to use: load-balancer liveness probes, uptime monitors that only care "is the process alive?". Safe at high frequency.
GET /health/ready
Readiness probe. Verifies the service can actually handle traffic — database reachable, memory engine initialised, embedding backend ready. This is what the post-deploy smoke hits to confirm a Railway deploy rolled out successfully.
Response:
| Field | Type | Description |
|---|---|---|
ready | boolean | true only when every sub-check passes. |
checks.database | boolean | DB pool is connected and SELECT 1 succeeds. |
checks.engine | boolean | MemoryEngine initialised and wired to storage. |
checks.embedding | boolean | Local model loaded OR API backend responded to warm-up. |
version | string | Service version. |
Example:
curl https://core.mnemoverse.com/api/v1/health/ready
# {"ready":true,"checks":{"database":true,"engine":true,"embedding":true},"version":"1.0.0"}When to use:
- Kubernetes / Railway readiness probe — route traffic only after this returns 200 with every sub-check
true. - Post-deploy smoke tests. Reference retry shape: poll up to 8 × 15 s (2 min cap, enough for a typical Railway rollout), assert HTTP 200 AND
ready === trueAND everychecks.*field istrue. Anyfalse= deploy regression; fail the smoke. - Your own monitoring: alert when
ready=falsepersists for longer than a deploy window (~2 min).
Status code: always 200 while the process is up. Sub-check values live in the body — ready=false is still a 200 response. This keeps the health surface distinct from errors the retry-wrapping proxies would otherwise treat as transient.
Error Responses
All errors follow a consistent format:
{
"code": "UNAUTHORIZED",
"message": "This API key is not recognized. Check that the whole key was copied, or create a new key at https://console.mnemoverse.com/dashboard/keys.",
"requestId": null,
"retryable": false,
"details": {
"reason": "invalid_key",
"keys_url": "https://console.mnemoverse.com/dashboard/keys"
}
}That body is a real one, captured from the live API on 2026-09-20 with a well-formed key that does not exist. details is null for most errors and an object where the API has something machine-readable to add, as it does here. requestId is null on this particular error because the key check runs before a request id is assigned; other errors carry one.
| HTTP Status | Code | Description |
|---|---|---|
| 400 | VALIDATION_ERROR | The request failed validation, on any route: a malformed body, a missing field, or a value outside its schema bounds or refused by a field check, answered before the route handler runs with message "Request validation failed" and one details.errors entry (loc, msg, type) per failure. Also covers a malformed or duplicate supersedes ID, a supersedes list over 32 items, a non-empty supersedes on an xroom:* write, or an external_ref that starts with the reserved secret: prefix, which on a batch route refuses the whole batch (see POST /memory/write) |
| 401 | UNAUTHORIZED | The API key is missing or was rejected. details.reason says which of five cases it is: see Reasons for a 401 |
| 403 | FORBIDDEN | API key lacks permission for the requested action (NOT raised on cross-tenant reads — those return an empty result set) |
| 404 | NOT_FOUND | Referenced atom / domain does not exist. A supersedes target that belongs to another organization returns the same 404 as one that does not exist at all: deliberate, so a caller cannot use the response to probe for other tenants' atom IDs |
| 409 | CONFLICT | The write conflicts with existing state. Under supersedes: a target is already superseded, is a secret atom, or was just claimed by a concurrent write racing yours. Also covers an external_ref collision on POST /memory/write: this returned an unhandled 500 before this release, and now returns 409 and names external_ref in the message |
| 422 | VALIDATION_ERROR | Returned only by the two batch-write routes, when an item carries supersedes, which they refuse outright (see POST /memory/write-batch). A request that fails validation answers 400, never 422 |
| 429 | RATE_LIMITED | Too many requests, daily quota exceeded, or a write blocked by subscription state. See Rate Limits for the three response shapes |
| 500 | INTERNAL | Server-side error (may be retryable) |
Reasons for a 401
A rejected key is not one failure, and the API says which one it is. Branch on details.reason; the message is written for a person and its wording can change.
details.reason | When | The message starts with |
|---|---|---|
missing_key | No key arrived: neither X-Api-Key nor Authorization: Bearer was sent, or it was empty | Missing API key. Send X-Api-Key header. |
placeholder_key | The key is an example value from the documentation, such as mk_live_YOUR_KEY | This API key is the placeholder from the documentation, not a real key. |
malformed_key | The value does not have the shape of a key at all. A key cut short in the paste lands here, and so does an OAuth token sent as an API key. The message itself states the shape the API expects | This value does not have the shape of a Mnemoverse API key |
invalid_key | The shape is right and no such key exists | This API key is not recognized. |
revoked_key | The key existed and was revoked. It will not work again | This API key was revoked. |
Every one of them carries details.keys_url, the console page where keys are created. None of them echoes any part of the value that was sent.
The codes above are the exact strings the API returns in the code field of the error body — match them with === / == in agent code, not by substring. There is no AUTH_INVALID or INTERNAL_ERROR code; older docs that mentioned them are wrong.
Rate Limits
Rate limits depend on your plan:
| Plan | Requests/min | Queries/day | Atoms |
|---|---|---|---|
| Free | 60 | 1,000 | 10,000 |
| Pro | 600 | 50,000 | 500,000 |
| Team | 3,000 | 500,000 | 5,000,000 |
| Enterprise | Custom | Custom | Custom |
Three independent limits can each return their own 429 RATE_LIMITED response. All three follow the error format above; only retryable, the details payload, and the response headers differ.
Per-minute rate limit
Enforced per organization (all API keys in an organization share the same limiter). Rate limit headers are included in every response:
X-RateLimit-Limit: 60
X-RateLimit-Remaining: 58
X-RateLimit-Reset: 1712345678When exceeded, the response also carries Retry-After and is retryable:
Retry-After: 12{
"code": "RATE_LIMITED",
"message": "Rate limit exceeded (60/min)",
"requestId": "req_abc123",
"retryable": true,
"details": null
}Daily quota
Counts reads, writes and feedback together (only successful 2xx/3xx requests are counted; a room create/join/invite POST is billed as a write). Not retryable within the same day:
X-DailyLimit-Limit: 1000
X-DailyLimit-Used: 1000
X-DailyLimit-Remaining: 0{
"code": "RATE_LIMITED",
"message": "Daily limit reached (1000/1000). Upgrade at https://console.mnemoverse.com/dashboard/usage",
"requestId": "req_abc123",
"retryable": false,
"details": {
"daily_limit": 1000,
"daily_used": 1000,
"upgrade_url": "https://console.mnemoverse.com/dashboard/usage"
}
}Subscription / atom limit
Blocks writes, reads always continue to work. Returned when the subscription is suspended, payment is past due, the atom limit is exceeded past its grace period, or an administrator has stopped the organization. Not retryable. The atom limit is measured against total_atoms in GET /memory/stats, so Vault secrets do not count toward it; POST /vault/secrets has a separate cap on the number of stored secrets. details.reason tells you which case: subscription_suspended, atom_limit_exceeded, payment_past_due, or organization_banned.
organization_bannedis set by an administrator to stop abuse. Writes and deletions on the memory routes (DELETE /memory/reset,DELETE /memory/atoms/{atom_id},DELETE /memory/domain/{domain}) are refused, reads keep working, and the block applies to every API key of the organization, project keys included. The message reads: "This organization's writes and memory deletions have been disabled by an administrator. Reads continue to work. Contact support if you believe this is an error." To dispute it, use contact. Underorganization_banned,POST /vault/secretsis refused as well, with429 RATE_LIMITED,details: null(so nodetails.reason) and its own message: "This organization's writes have been disabled by an administrator. Existing secrets remain usable. Contact support if you believe this is an error."- For the other three reasons deletions on these routes still work, so an organization over its atom limit can delete memory atoms that are not Vault secrets to get back under it. The per-atom and per-domain routes do not delete secret atoms:
DELETE /memory/atoms/{atom_id}answers403 FORBIDDENfor one,DELETE /memory/domain/{domain}skips them and reports the count inskipped_secrets, and the Vault has no delete endpoint.
On the memory routes the response body has the same shape for every reason; for organization_banned, details.status is banned. The Vault refusal above is the exception: its details is null.
{
"code": "RATE_LIMITED",
"message": "Atom limit exceeded (10,000). Upgrade or delete memories. Reads continue to work.",
"requestId": "req_abc123",
"retryable": false,
"details": {
"tier": "free",
"status": "over_limit",
"reason": "atom_limit_exceeded",
"grace_until": "2026-10-15T00:00:00+00:00",
"upgrade_url": "https://console.mnemoverse.com/dashboard/usage"
}
}