Skip to main content

Memories

A memory is one fact an agent knows. A memory store is the named container that holds many of them.

Overview​

A memory store is a namespace for text content that agents read and write during generation. Each store holds many memories, embedded for semantic search via the Knowledge module.

Agents retrieve relevant memories via knowledge_config and write new facts with the built-in write_memory tool; a store also decides for itself what completed turns may contribute to it, through its memory rules. See Agent Integration and the Memory & Knowledge Engine deep dive. In the engine & algorithms pattern, the write funnel, embedding, the assertion ledger and invalidation are the engine; the write algorithm and a rule's handler are the algorithms, with customization seams in the deep dive.

A memory is state. Every write that produced or changed it is an assertion: some principal, through some mechanism, claimed a fact. The two are separate records, which is what lets a write that changed nothing still leave a trace. How memories and documents together serve as one record for many writers: Single Source of Truth.

See the Permissions Reference for the IAM action strings for this module.

Data Model​

Memory Store​

FieldTypeDescription
idstringPublic ID (mstore_ prefix)
project_idstringID of the owning project
namestringHuman-readable name
descriptionstring | nullOptional description
tagsobjectOptional key-value labels for filtering by category
duplicate_thresholdnumber | nullThe store's skip cutoff; null uses the algorithm constant — see Where the thresholds come from
supersede_thresholdnumber | nullThe store's supersede cutoff; null uses the algorithm constant
created_atstringISO 8601 creation timestamp
updated_atstringISO 8601 last-updated timestamp

Memory​

When a memory is created or updated, its content is embedded for semantic similarity search. The text and its vector are stored once per distinct text per store and shared: a memory holds no copy of its own, and restating text the store already has costs no embedding call at all.

FieldTypeDescription
idstringPublic ID (mem_ prefix)
memory_store_idstringID of the parent memory store
contentstringText content of the memory
source_typestringWhether there is a source to point at: manual (default) or conversation — see Provenance
source_idstring | nullThe conversation this fact was learned in when source_type is conversation; null when manual
tagsobjectPer-memory key-value labels for memory-granularity tag filtering in Knowledge search
metadataobjectCaller-owned annotations on the memory — see Tags and metadata
invalidated_atstring | nullWhen the memory stopped holding, superseded or retracted; null means currently valid — see Temporal invalidation
superseded_by_memory_idstring | nullThe memory that replaced this one, when superseded; null when a retraction withdrew it
versionintegerWrite version, starting at 1 and incremented on every update — see Concurrent writes
created_atstringISO 8601 creation timestamp
updated_atstringISO 8601 last-updated timestamp

Memory Assertion​

One row per write attempt, append-only, whatever the write resolved to.

FieldTypeDescription
idstringPublic ID (massert_ prefix)
memory_store_idstringID of the store written to
memory_idstring | nullThe memory the write resolved into: the new memory for created and superseded, the memory that matched for skipped, the memory withdrawn for retracted
superseded_memory_idstring | nullThe memory this assertion retired, on a superseded outcome
contentstringThe text as asserted, which is not always the memory's text — a skipped assertion records what was claimed
mechanismstringWhich door the write came through: tool, rule, api or formation — see Mechanism
rule_idstring | nullThe memory rule whose firing wrote this (mrule_ prefix); set only for rule. null once that rule is deleted
generation_idstring | nullThe turn that asserted the fact; null on the api and formation doors
principal_typestringPrincipal that claimed it: user or api_key on the api and formation doors, agent on the agent doors
principal_idstringThe principal's public ID
outcomestringcreated, superseded, skipped or retracted
similaritynumber | nullThe cosine the outcome was decided against — the top match's, or the declared target's when declared is true; null when there was nothing to compare against, and on a retraction, which compares nothing
declaredbooleanWhether the caller named the memory this write replaced (supersedes) instead of the thresholds choosing one
created_atstringISO 8601 creation timestamp

Key Concepts​

What belongs in a memory​

A memory is a fact the agent learns about the world (a customer's shipping address, a decision a team reached, a constraint discovered while working), retrieved by semantic similarity and consumed as context.

Retrieval is approximate: the shared content row's vector carries an HNSW index, so a similarity search reads a bounded candidate list rather than scanning every memory. Recall against the exact top-k is below 1.0, and min_similarity thresholds tuned against an exact scan may select a slightly different set. See Ranking is approximate; it applies to memory search and to the dedup similarity check below alike.

A correction to the agent's behavior ("never quote a delivery date without checking stock") is doctrine, not a fact, and its application must not depend on retrieval rank. It has two homes:

  • A constraint that must never be violated — a guardrail deny, which refuses the action deterministically.
  • Guidance the model should follow — the agent's instructions, which agent versions archive on every write.

When the same correction keeps being made by hand, the approvals recurrence view surfaces it.

Write Algorithm​

Every write to a memory store — REST, the agent tool, a post-turn rule, a formation — goes through the same three-outcome algorithm. There is no model call anywhere in it.

  1. Resolve the content. The text is matched against what the store already holds, by a hash of its trimmed, whitespace-collapsed form. A hit reuses the stored row and its vector and reaches no embedder; a miss embeds once and stores the pair.
  2. Find the top match — the most similar currently-valid memory in that store, by cosine. Invalidated memories are never candidates.
  3. Resolve to one outcome:
Similarity rangeOutcomeWhat happens
≥ duplicate_thresholdskippedThe fact is already known. The existing memory is returned unchanged.
≥ supersede_thresholdsupersededThe same fact, changed. The match is invalidated and points at a new memory holding the new text.
below itcreatedA distinct fact. A new memory is written.

Every call records exactly one assertion, whatever the outcome.

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. PUT /api/v1/memories/:id replaces tags/metadata outright; pass null (or {} for tags) to clear.

Below supersede_threshold, cosine covers both "same fact, changed" and "related but distinct" (prefers email vs prefers Portuguese), and embeddings sit close on negations. created is the outcome there because a near-duplicate stays searchable while a wrongly retired fact does not.

Declaring the supersede​

The bands retire a contradiction only when the two texts are near-identical. The office is in Lisbon and We closed the Lisbon office contradict each other and score nowhere near supersede_threshold, so both would stay live and searchable. Lowering the threshold to catch that would start retiring merely-related facts.

supersedes on POST /api/v1/memories names the memory a write replaces. The declaration outranks the bands in both directions: the named memory is retired and the outcome is superseded whether the two texts score above duplicate_threshold or far below supersede_threshold. A declaration is not a similarity question, so the top-match search is not run at all — nothing else in the store is touched.

The declarationResult
A still-valid memory in the same store, and the caller may update it200, superseded — the target is retired and the response is the replacement
A memory in another store400 VALIDATION_FAILED
A memory already superseded400 VALIDATION_FAILED — no chaining; supersede the replacement instead
No memory with that id404 RESOURCE_NOT_FOUND

Authorization is the write grant plus the target's: memories:CreateMemory on the store, and memories:UpdateMemory on the memory named — evaluated through the same two tag bags PUT /api/v1/memories/{memory_id} uses, so soat:ResourceTag conditions apply to the target as on any update. A declaration therefore says no more, and does no more, than updating that memory directly would.

The replacement inherits the retired memory's tags and metadata exactly as a threshold supersede does, and the assertion records declared: true with the cosine to the target — which decides nothing here, and is the number that says how far apart the two statements were.

Where the thresholds come from​

Three layers, resolved request → store → constant, each value independently; the first non-null wins.

LayerWhereScope
Per-requestduplicate_threshold / supersede_threshold on POST /api/v1/memoriesthat one call
Store defaultduplicate_threshold / supersede_threshold on the memory store, settable on create, update and as a formation resource propertythe corpus's dedup policy
Algorithm constantbuilt in0.95 / 0.90

Only the api door takes per-request values. The write_memory tool and a memory rule always use the store's effective pair: a writer that could loosen the corpus's dedup policy from the side would make the store-level default meaningless. A formation sets store defaults through the store resource, never per memory.

Invariant: the effective pair must satisfy supersede_threshold < duplicate_threshold. Equal makes superseded unreachable; inverted swallows skipped. Both are rejected with 400 VALIDATION_FAILED, on the store write and on the request — and on the request the check runs against the effective pair, so a body overriding only one value cannot invert it against the store's other one. Each value is bounded to [0, 1].

Response action Field​

The response always includes an action field alongside the memory:

actionHTTP statusMeaning
created201New memory written
superseded200The match was invalidated and replaced; the replacement is returned
skipped200The fact was already known; the existing memory is returned unchanged

Assertions​

A memory row is state. The write that produced it is an event, recorded separately: one memory_assertion per write attempt, appended whatever the outcome, including the writes that changed nothing.

An assertion names the content as asserted (not always the memory's text), the outcome and the similarity that chose it, whether the supersede was declared by the caller rather than chosen by the thresholds, the principal who claimed the fact, the mechanism it came through, and — for anything an agent wrote — the generation it happened in.

Mechanism​

mechanism answers through which door, never who:

mechanismThe write
toolthe agent's write_memory call, mid-turn
rulea memory rule firing on a finished turn, named by rule_id
apiPOST /api/v1/memories
formationa memory resource in an applied formation

The who is principal_type / principal_id. On both agent doors the principal is the agent whose turn it was: a rule decides what the corpus accepts, but the agent is who said the thing. The generation's own started_by names whoever asked for the turn, which is a different question again.

Reading the ledger​

The memory and store listings are gated on memories:ListMemoryAssertions, against the store's SRN like every other item read. The generation listing is gated on generations:GetGeneration.

Validity — invalidated_at and superseded_by_memory_id — is on the memory, not on the assertion: it is the filter on every read, and one memory has one answer however many writes reached it. superseded_memory_id is read back from the memory, and is unique because a write supersedes exactly its top match; a retracted assertion leaves it null, because the memory it retired is the one memory_id already names.

Provenance​

source_type answers one question: is there a source to point at?

source_typesource_idWritten by
conversationthe conversation's public IDa memory rule firing on a conversation turn
manualnullPOST /api/v1/memories, the write_memory tool, and a rule firing on a bare agent generation

It deliberately does not describe the write mechanism. A fact the write_memory tool wrote during a generation that belongs to no conversation reads manual, because there is nothing to name — not because a human typed it.

Provenance is recorded when the memory is created and never rewritten. A turn that replaces the fact supersedes it with a new memory carrying its own provenance. Which door a write came through, and which turn made it, are on the assertion instead.

source_id is a loose pointer, not a foreign key: deleting the conversation leaves the id in place. The fact was learned there, and the record of where it came from outlives its source.

See Agent with Persistent Memory - Step 13 (Trace a fact back to the conversation it came from).

Temporal invalidation​

A memory that no longer holds is retired rather than rewritten: superseding sets invalidated_at and points superseded_by_memory_id at the replacement, and retracting sets invalidated_at with nothing to point at.

Invalidated memories are excluded from:

  • memory listing (GET /api/v1/memories) unless include_invalidated=true is passed
  • write deduplication — a retired fact is never a match candidate, so restating superseded knowledge creates a new memory
  • Knowledge search, so a retired fact is never injected into a generation

They stay readable by ID (GET /api/v1/memories/{memory_id}), with their original text and their own assertions, for audit.

A supersede and a retraction are the two ways a memory reaches that state — the first because the fact changed, whether the thresholds chose the memory or the caller declared it, the second because it stopped holding with nothing taking its place. DELETE remains the way to remove a memory outright.

Retraction​

POST /api/v1/memories/{memory_id}/retract withdraws a fact. The memory leaves every default read at once, because a retraction is an invalidation and validity is already the filter each of them applies — there is no second exclusion to keep in sync.

What tells a retraction from a supersede is what it leaves behind: superseded_by_memory_id stays null, and the ledger gets an assertion with outcome retracted naming who withdrew the fact. Restating the fact later lands as a new memory, since dedup only ever matches a valid one.

The write claims the memory's version, so it takes expected_version or If-Match like any other write. A memory that is already invalidated — retracted, or superseded by a later write — answers 409 MEMORY_ALREADY_INVALIDATED.

Concurrent writes​

PUT /api/v1/memories/{memory_id} may name the version it is changing, either expected_version in the body or an If-Match header, and is refused with 409 VERSION_CONFLICT when the memory has moved on since — see Concurrent Writes. A caller that states nothing still gets the version bump: two writers racing on the same memory serialize, and the loser sees the conflict rather than silently overwriting the winner. A retraction claims the same counter, so it is refused the same way.

NDJSON export​

GET /api/v1/memory-stores/{memory_store_id}/export streams one store's memories as newline-delimited JSON, oldest first, one memory object per line.

  • Invalidated memories — retracted or superseded — are left out unless include_invalidated=true, so the file holds what the store currently asserts.
  • ?tags= narrows it exactly as it narrows the listing, and a policy condition on a memory's own tags applies the same way.
  • The response streams and pages internally.
  • Authorized by memories:ExportMemories, separate from memories:ListMemories.

Tag Filtering​

Tags are free-form strings attached to a memory store at creation or update time.

POST /api/v1/memory-stores
{
"project_id": "proj_abc",
"name": "Customer Preferences",
"tags": { "domain": "customer", "system": "crm" }
}

Tags are key-value pairs, the same shape documents, actors, files, conversations and sessions carry, and the shape the IAM soat:ResourceTag/<key> condition reads.

The tags query parameter on GET /api/v1/memory-stores filters by pair, written key:value in the query string:

# every pair must match, exactly and case-sensitively
GET /api/v1/memory-stores?tags=domain:customer&tags=system:crm

The split is on the first colon, so a value may contain colons of its own (url:https://example.com). Repeat the parameter for several pairs; all must be present. A value with no colon is rejected rather than guessed at.

The tag map is also its own sub-resource — GET /api/v1/memory-stores/:id/tags, PUT /api/v1/memory-stores/:id/tags (replace) and PATCH /api/v1/memory-stores/:id/tags (merge) — the same three routes every tagged resource exposes. All three return the tag map, not the store. See IAM — Tags.

Tag Conditions​

A policy condition on soat:ResourceTag/<key> reads these tags, so access can be granted by attribute rather than by id. Both bags take part, at the level that owns them:

SurfaceWhat the condition reads
GET /api/v1/memory-stores, and every single-store routethe store's own tags
GET /api/v1/memoriesthe owning store's tags to reach the listing at all, then each memory's own tags to narrow it
A single memory, and the memory tag sub-routesthe memory's own tags and its store's — both must permit
Knowledge searchthe same pair, compiled into the query: a memory is returned only when its store and itself are both permitted

A memory is therefore never more visible than the store holding it: a condition that hides a store hides its memories, whatever they carry. A resource with no tag at all satisfies StringNotEquals — an absent key is not the excluded value — so untagged stores and memories stay visible under a StringNotEquals rule.

Walk it end to end in Tag-Based Access Control.

Memory-Level Tag Filtering​

Memories carry their own tags (and optional metadata), independent of the store's tags. GET /api/v1/memories filters them with the same ?tags=key:value parameter, and GET /api/v1/memories/:id/tags, PUT /api/v1/memories/:id/tags and PATCH /api/v1/memories/:id/tags manage the bag without touching content. tags in Knowledge search and an agent's knowledge_config.tags match at memory granularity: a memory is returned when its parent store's tags contain the pairs (store-level, all its memories returned) or its own tags do (only that memory returned). A single store can thus hold memories for many roles/sources: tag captured rules with role: traffic-manager and source: rejected_approval, then search tags: { "role": "traffic-manager" } to read only those.

soat create-memory \
--memory-store-id mstore_01 \
--content "Reject refunds above $500 for the traffic-manager role" \
--tags '{"role": "traffic-manager", "source": "rejected_approval"}' \
--metadata '{"evidence": "high"}'

Agent Integration​

Automatic Knowledge Retrieval​

Set knowledge_config on an agent to have the server search relevant memories before every generation and inject them as a delimited reference-context message (never as system content, since memories can be user-derived). See Knowledge Config.

write_memory Tool​

Set write_memory_store_id in the agent's knowledge_config to inject a write_memory tool into every generation. The tool accepts a single content input, the atomic fact to write. The target store is fixed by write_memory_store_id; the agent cannot choose another, and it cannot set thresholds — the store's effective pair applies.

The agent's boundary_policy gates the tool: it must allow memories:CreateMemory and memories:UpdateMemory (a write may supersede) on the target store's SRN, srn:<project_id>:memory_store:<memory_store_id>, with the store's tags as condition inputs. A boundary scoped to one store therefore holds even if write_memory_store_id is later pointed elsewhere.

Memories written by the tool carry source_type: "manual": source_id is a pointer a client supplies on a hand-written fact, and the tool has none to give. The turn behind the write is on its assertion, with mechanism: "tool" and the agent as principal.

{
"knowledge_config": {
"memory_store_ids": ["mstore_alice"],
"write_memory_store_id": "mstore_alice"
}
}

Memory Rules​

A memory rule is a store's ingestion policy: what a completed agent turn is allowed to contribute to this corpus, and who decides. It lives on the destination, because the question it answers — "what feeds this store?" — is a property of the store, not of any one agent.

That makes it the opposite half of the write_memory tool, which is a capability grant on an agent and stays where it is:

write_memory toolmemory rule
Who decidesthe agent, mid-turn, at its discretionthe platform, after every completed turn
What it isa capability grant — this agent may write therean ingestion policy — this is what the corpus accepts
Readsthe agent's whole contextexactly one turn's transcript
Lives onthe agentthe memory store

Because a rule belongs to its store, one store can have several (a cheap general one, a strict one for billing turns), one agent can feed two stores under different rules, and "what feeds this store?" is one listing instead of a sweep over every agent in the project.

The rule​

FieldTypeDescription
idstringPublic ID (mrule_ prefix)
memory_store_idstringThe destination store, and the rule's owning scope. Deleting the store deletes its rules
project_idstringThe store's project
onstringThe event the rule reads
source_agent_idsarray | nullAgents whose turns it reads; null is every agent in the project
agent_idstring | nullHandler agent — mutually exclusive with tool_id
tool_idstring | nullHandler tool — mutually exclusive with agent_id
actionstring | nullOperation id, for a tool handler
preset_parametersobject | nullMerged into a tool handler's input; the turn's own fields are reserved and win
promptstring | nullReplaces the built-in extractor's task instructions
ai_provider_idstring | nullProvider override for the built-in extractor
modelstring | nullModel override for the built-in extractor
enabledbooleanA disabled rule is kept and never fires
created_atstringISO 8601 creation timestamp
updated_atstringISO 8601 last-updated timestamp

Manage them with POST /api/v1/memory-rules, GET /api/v1/memory-rules (pass memory_store_id to read one store's policy), GET /api/v1/memory-rules/{memory_rule_id}, PATCH /api/v1/memory-rules/{memory_rule_id} and DELETE /api/v1/memory-rules/{memory_rule_id}. Every one is authorized against the store's SRN, with memories:{Create,Get,List,Update,Delete}MemoryRule.

Events​

onFiresFit
generations.completedonce per completed turn — conversation or bare, streaming or notturn-level extraction, and the only event the built-in extractor may bind to
conversation_messages.generatedonce per persisted assistant replyconversation-backed only; fine for a custom handler

conversation_messages.created is deliberately not offered: it fires per message, including the user's and before the reply, so a rule bound there would read half a turn.

Handlers​

A rule's handler decides what is worth remembering. It proposes candidates and never writes:

{ "facts": [{ "content": "Customer prefers email", "tags": { "kind": "preference" } }] }
HandlerSetBehaviour
built-in extractorneither agent_id nor tool_idA tool-less completion over the turn's transcript asking for a JSON array of atomic facts. prompt, ai_provider_id and model tune it
agentagent_idThe agent is generated against the transcript and its reply is parsed as the contract above
tooltool_id (+ action)The tool is called with { event, rule_id, agent_id, generation_id, conversation_id, transcript } plus preset_parameters, and its output is parsed as the contract above

Because the write algorithm has no model call in it, an agent handler and a tool handler behave identically once they return — a tool handler is not a degraded path.

The server then runs each candidate through the standard write algorithm on the store's effective thresholds, against the project's storage_bytes quota, and appends one assertion. A handler can propose garbage and cannot corrupt the store. Anyone who wants to bypass the algorithm still has POST /api/v1/memories.

The three fields that configure the built-in extractor cannot be combined with a handler: a handler makes its own model call, or none, so they would be accepted and ignored.

Provider resolution for the built-in extractor: ai_provider_id → the source agent's pinned provider → the agent's model_route_id → the project's default_model_route_id. Model resolution for the provider cases: the rule's model → the override provider's default_model (when ai_provider_id is set) → the agent's model → the agent provider's default_model. A provider override falls back to that provider's default because the agent's model name is usually meaningless on a different provider. When resolution lands on a route, each target names its own model (so model does not apply), the call gets ordered provider failover, and it is metered against the target that served.

The custom prompt controls what to extract, not the response format; the server always appends the JSON-array contract line and the transcript.

What a firing records​

  • Each write appends an assertion with mechanism: "rule", the rule's rule_id, and the turn's generation_id. The principal is the source agent.
  • Memories from a conversation turn carry source_type: "conversation" and its id in source_id; a bare agent generation has no conversation, so those read manual.
  • Rules bound to generations.completed record a summary on the originating generation's extraction field (Generations API), keyed by rule id: { "mrule_…": { candidates, created, superseded, skipped } }. A store can have several rules, so one flat pair of counts could not say which produced them. Firings on conversation_messages.generated are visible in the assertion ledger, like every other write.
  • A rule never blocks or fails the turn it reads: a handler that throws, times out, or answers with nonsense contributes nothing.

Loop guard​

A handler agent's own generation completes and emits generations.completed like any other, so the dispatcher skips two kinds of turn: one it started itself (the generation is stamped source: "memory_rule"), and one by an agent that handles any rule in the project — which is what keeps "test the handler by hand" from becoming an infinite mill. A handler generation also declares the source turn as its initiator, so it inherits that turn's trace lineage and continuation budget.

Example​

Two rules on one store, different handlers, different selectors — unexpressible when extraction lived on the agent:

{
"resources": {
"SupportFacts": { "type": "memory_store", "properties": { "name": "Support facts" } },

"GeneralExtraction": {
"type": "memory_rule",
"properties": {
"memory_store_id": { "ref": "SupportFacts" },
"on": "generations.completed",
"source_agent_ids": [{ "ref": "SupportAgent" }],
"model": "<cheap-model>"
}
},

"BillingExtraction": {
"type": "memory_rule",
"properties": {
"memory_store_id": { "ref": "SupportFacts" },
"on": "generations.completed",
"source_agent_ids": [{ "ref": "BillingAgent" }],
"agent_id": { "ref": "StrictBillingExtractor" }
}
}
}
}

See Agent with Persistent Memory - Step 11 (Add a memory rule).

Examples​

Create a memory store​

soat create-memory-store \
--project-id proj_ABC \
--name "Customer Preferences" \
--tags '{"domain": "customer", "system": "crm"}'

Write a memory​

soat create-memory \
--memory-store-id mstore_01 \
--content "Customer prefers email over phone calls"