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: 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 |
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 atom passed the importance gate |
atom_id | UUID | UUID of the stored atom, or null if filtered |
importance | float | Computed importance score [0, 1] |
reason | string | Why stored or filtered |
superseded | UUID[] | IDs of atoms this write marked as superseded. Always present; [] when supersedes was omitted or empty |
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 not live yet
A superseded atom is marked (superseded_by is set) and fully auditable through GET /memory/atoms/{atom_id}, but it is not yet hidden anywhere else. POST /memory/read, /memory/recent, /memory/query, and batch fetches still return superseded atoms exactly as before the read-side filter ships. Don't rely on supersedes to remove a stale fact from search results today; it records the correction, it does not yet hide the original.
POST /memory/write-batch
Store up to 500 atoms in one request.
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-100, 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) |
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 (similarity * valence modulation) |
similarity | float | Raw cosine similarity |
valence | float | Outcome polarity [-1, +1] |
importance | float | Importance score [0, 1] |
source | string | Hit source: episodic, semantic, or hebbian |
concepts | string[] | Associated concepts |
domain | string | Domain namespace |
metadata | object | Arbitrary metadata |
provenance | object | Authorship record — present on authored (Rooms) atoms |
POST /memory/read-batch
Batch query up to 50 queries in one request.
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.
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 |
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 |
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) |
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 after update |
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. Measured on 2026-08-13: the largest tenant on the hosted service held 2,429 memories against a free-tier allowance of 10,000, and the whole store came to roughly 16,000 across 200 tenants. No tenant is near its own 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 the self-hosted engine, where an operator may enable it deliberately.
| Field | Type | Description |
|---|---|---|
domain | string | Domain consolidated |
atoms_before | int | Atom count before |
atoms_after | int | Atom count after. With soft consolidation (the default) the sources are kept and the prototypes are added, so this count rises rather than falls, and compression_ratio comes out below 1 |
prototypes_created | int | New prototype atoms |
singletons_protected | int | Von Restorff protected atoms |
compression_ratio | float | atoms_before / atoms_after |
duration_ms | float | Duration in milliseconds |
GET /memory/stats
Get memory statistics for your tenant.
Response:
| Field | Type | Description |
|---|---|---|
total_atoms | int | Total atoms stored |
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 |
avg_valence | float | Average outcome valence |
avg_importance | float | Average importance score |
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.
The envelope also carries 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 is the one entry point that always returns a superseded atom in full, regardless of the read-side caveat under POST /memory/write above.
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. What is not live yet is the read-side filter that hides a superseded atom from default search results; see the caveat in the write section. Until that ships, treat supersedes as an audit trail, not a way to make a stale fact stop showing up in /memory/read.
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.
If the deleted atom sat in a supersession chain, the response also carries two loud fields:
| 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 the read-side filter ships, though always fetchable by ID. Empty unless this atom had its own supersedes chain |
history_destroyed | boolean | true when the list above is non-empty: this delete broke a supersession chain's visibility |
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, experimental setups; 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.
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.
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.
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 are single-use and expire.
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.
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": "Invalid or missing API key",
"requestId": "req_abc123",
"retryable": false,
"details": null
}| HTTP Status | Code | Description |
|---|---|---|
| 400 | VALIDATION_ERROR | Invalid request body / schema constraints violated (returned by core's MnemoError(VALIDATION_ERROR, ..., 400) path). Also covers a malformed or duplicate supersedes ID, a supersedes list over 32 items, or a non-empty supersedes on an xroom:* write (see POST /memory/write) |
| 401 | UNAUTHORIZED | Missing or invalid API key |
| 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 | FastAPI/Pydantic-level validation failure (raised before reaching the route handler) |
| 429 | RATE_LIMITED | Too many requests. Check Retry-After and X-RateLimit-Reset headers |
| 500 | INTERNAL | Server-side error (may be retryable) |
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 |
Rate limit headers are included in every response:
X-RateLimit-Limit: 60
X-RateLimit-Remaining: 58
X-RateLimit-Reset: 1712345678