Memories
The individual facts inside a memory store — embedded, deduplicated, and superseded rather than deleted.
Overview
A memory is one fact: a sentence of content, a source_type saying whether
there is a source to point at, and tags. Writing one embeds it, so
knowledge search can rank it against a query alongside the
project's documents.
Memories are not a log. A near-duplicate write is skipped rather than appended, and a memory that gets contradicted is invalidated and pointed at its replacement instead of being removed — so what an agent recalls stays coherent while the history of how it got there stays readable.
This module is a verbatim mirror of the runtime: every field, method, status code and error shape is the runtime's own, re-rooted under the project in the path.
Free stores 1 GB, Pro 30 GB, Business 100 GB; an Enterprise ceiling is set by
contract. Creating a memory past that answers 403 plan_limit_reached with
resource: "storage", and the plan, the limit and what your account is
holding as storage_gb in details.
The ceiling is your account's, not each project's: every project the
account pays for draws on the same figure, so one project may hold all of it.
Read where you stand from storage_gb and storage_limit_gb on
GET /v1/users/me/billing; the
usage route below reports one project at a time.
A memory counts for its text plus its embedding vector, which is about 4 kB
whatever the text — so many small memories cost far more than their words
suggest. The same ceiling covers files and documents; read your current figure
from the gb_day component of
GET /v1/projects/{project_id}/usage
under meter_type=storage.
Reading, updating and deleting stay open at the ceiling; only creates are refused.
See the OpenAPI spec for the full endpoint and schema reference, or browse it rendered under API Reference → Memories.
Data Model
Memory
| Field | Type | Description |
|---|---|---|
id | string | Public memory ID (mem_ prefix). |
memory_store_id | string | The memory store holding it. Required on create. |
content | string | The fact. Required on create. |
source_type | string | manual or conversation — see Where a fact came from. |
tags | object, nullable | Per-memory key-value tag map. |
metadata | object, nullable | Arbitrary structured metadata, stored verbatim. |
source_id | string, nullable | Public ID of the conversation the fact was learned in, when source_type is conversation. Null for manual. |
invalidated_at | string (date-time), nullable | Non-null once superseded — see Superseded, not deleted. |
superseded_by_memory_id | string, nullable | The memory that replaced this one. |
created_at | string (date-time) | |
updated_at | string (date-time) |
A write response adds one field:
| Field | Type | Description |
|---|---|---|
action | string | created, skipped or superseded — see What a write returns. |
Key Concepts
Listing is always per store
GET /v1/projects/{project_id}/memories
requires memory_store_id; there is no cross-store listing. That follows from
what a store is — a scope — and it means a caller cannot accidentally read one
customer's facts while paging through another's.
Give an agent long-term memory
lists its store this way.
Invalidated memories are left out by default. Pass include_invalidated=true to
see the full history, which is what you want when answering "why does the agent
think that?".
Where a fact came from
source_type says whether there is a source to point at. conversation means
source_id names the conversation the fact was learned in,
and is required with it; manual means there is nothing to point at — a direct
API write, or an agent write made outside a conversation — and rejects
source_id.
source_id is a loose pointer rather than a foreign key: deleting the
conversation leaves the id in place, so the record of where a fact came from
outlives its source.
What a write returns
Every write is resolved against what the store already holds, by cosine similarity to the closest valid memory:
| Similarity | action | Status | What happens |
|---|---|---|---|
≥ duplicate_threshold | skipped | 200 | The fact is already known; the existing memory is returned unchanged. |
≥ supersede_threshold | superseded | 200 | The same fact, changed. The match is invalidated and the replacement is returned. |
| below both | created | 201 | A new memory. |
Below supersede_threshold, cosine cannot tell "same fact, changed" from
"related but distinct" (prefers email vs prefers Portuguese), and embeddings
sit close on negations — so created is the outcome there. A near-duplicate
stays searchable; a wrongly retired fact does not.
On a supersede the retired memory's tags and metadata are shallow-merged
onto the replacement (incoming keys win), so a replacement stays inside every
tag-scoped search and policy the original satisfied.
Where the thresholds come from
Both cutoffs resolve per request, most specific first:
| Level | Set on | Scope |
|---|---|---|
| Per request | duplicate_threshold / supersede_threshold on the create body | that one call |
| Store default | the same two fields on the memory store | the corpus's dedup policy |
| Algorithm | null at both levels | the built-in constants |
The effective pair must satisfy supersede_threshold < duplicate_threshold.
Equal makes superseded unreachable; inverted swallows skipped. Both are
refused with 400, and the check runs against the effective pair — so a body
overriding only one value cannot invert it against the store's other one.
Writing by hand versus by agent
Every door consolidates, and they differ only in what supplies the fact. This
endpoint takes one you wrote. When an
agent has
knowledge_config.write_memory_store_id set, the write_memory tool becomes
available to it, and the agent spends it mid-turn at its own discretion
(Limit what an agent may do
sets it up). A memory rule on the store is the third: it
reads completed turns and writes what its handler proposes, as in
Give an agent long-term memory.
Which door a write came through is recorded on the assertion, not inferred from the result.
Every write is recorded
Every write attempt is appended to a ledger, whatever its outcome — a skip that left no trace would make "why is this fact not in the store?" unanswerable.
| Field | Type | Description |
|---|---|---|
id | string | Public assertion ID (massert_ prefix). |
memory_store_id | string | The store written to. |
memory_id | string, nullable | What the write resolved into: the new memory for created and superseded, the memory that matched for skipped. |
superseded_memory_id | string, nullable | The memory this assertion retired, on a superseded outcome. |
generation_id | string, nullable | The generation that made the write, when an agent made it. |
mechanism | string | Which door the write came through. |
outcome | string | created, superseded or skipped. |
similarity | number, nullable | Cosine similarity to the closest match. |
created_at | string (date-time) |
Read it from either end — one memory's history, or a store's whole write log:
GET /v1/projects/{project_id}/memories/{memory_id}/assertionsGET /v1/projects/{project_id}/memory-stores/{memory_store_id}/assertions
Validity lives on the memory (invalidated_at, superseded_by_memory_id), not
on the assertion: it is the filter on every read, and an invalidation with no
replacement has no assertion to carry it.
Superseded, not deleted
An invalidated memory is excluded from listing, from deduplication and from
search — it stops affecting answers immediately — but the row stays, stamped with
invalidated_at and superseded_by_memory_id. Following that pointer forward,
or listing with include_invalidated=true, reconstructs the chain of what the
agent believed and when it changed.
Deleting a memory, by contrast, removes it.
Who may do what
Every route needs any project member.
Examples
Seed a store with a fact
- CLI
- SDK
- curl
naturali create-memory \
--project-id proj_V1StGXR8Z5jdHi6B \
--memory-store-id mstore_V1StGXR8Z5jdHi6B \
--content "Acme is on the enterprise plan and has a 4-hour response SLA." \
--source-type manual \
--tags '{"account":"acme"}'
const { data: memory } = await naturali.memories.createMemory({
path: { project_id: 'proj_V1StGXR8Z5jdHi6B' },
body: {
memory_store_id: 'mstore_V1StGXR8Z5jdHi6B',
content: 'Acme is on the enterprise plan and has a 4-hour response SLA.',
source_type: 'manual',
tags: { account: 'acme' },
},
});
console.log(memory?.action); // "created" or "skipped"
curl -X POST https://api.naturali.ai/v1/projects/proj_V1StGXR8Z5jdHi6B/memories \
-H "Authorization: Bearer $NATURALI_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"memory_store_id": "mstore_V1StGXR8Z5jdHi6B",
"content": "Acme is on the enterprise plan and has a 4-hour response SLA.",
"source_type": "manual",
"tags": { "account": "acme" }
}'
Read what a store holds
- CLI
- SDK
- curl
naturali list-memories \
--project-id proj_V1StGXR8Z5jdHi6B \
--memory-store-id mstore_V1StGXR8Z5jdHi6B \
--limit 20
const { data: memories } = await naturali.memories.listMemories({
path: { project_id: 'proj_V1StGXR8Z5jdHi6B' },
query: { memory_store_id: 'mstore_V1StGXR8Z5jdHi6B', limit: 20 },
});
curl "https://api.naturali.ai/v1/projects/proj_V1StGXR8Z5jdHi6B/memories?memory_store_id=mstore_V1StGXR8Z5jdHi6B&limit=20" \
-H "Authorization: Bearer $NATURALI_TOKEN"
Trace why the agent believes something
- CLI
- SDK
- curl
naturali list-memories \
--project-id proj_V1StGXR8Z5jdHi6B \
--memory-store-id mstore_V1StGXR8Z5jdHi6B \
--include-invalidated true
const { data: history } = await naturali.memories.listMemories({
path: { project_id: 'proj_V1StGXR8Z5jdHi6B' },
query: {
memory_store_id: 'mstore_V1StGXR8Z5jdHi6B',
include_invalidated: true,
},
});
for (const memory of history?.data ?? []) {
if (memory.invalidated_at) {
console.log(memory.content, '→ replaced by', memory.superseded_by_memory_id);
}
}
curl "https://api.naturali.ai/v1/projects/proj_V1StGXR8Z5jdHi6B/memories?memory_store_id=mstore_V1StGXR8Z5jdHi6B&include_invalidated=true" \
-H "Authorization: Bearer $NATURALI_TOKEN"
Correct a fact
- CLI
- SDK
- curl
naturali update-memory \
--project-id proj_V1StGXR8Z5jdHi6B \
--memory-id mem_V1StGXR8Z5jdHi6B \
--content "Acme is on the enterprise plan and has a 2-hour response SLA."
const { data: memory } = await naturali.memories.updateMemory({
path: {
project_id: 'proj_V1StGXR8Z5jdHi6B',
memory_id: 'mem_V1StGXR8Z5jdHi6B',
},
body: {
content: 'Acme is on the enterprise plan and has a 2-hour response SLA.',
},
});
curl -X PUT https://api.naturali.ai/v1/projects/proj_V1StGXR8Z5jdHi6B/memories/mem_V1StGXR8Z5jdHi6B \
-H "Authorization: Bearer $NATURALI_TOKEN" \
-H "Content-Type: application/json" \
-d '{ "content": "Acme is on the enterprise plan and has a 2-hour response SLA." }'