Memories
Named containers for storing and retrieving knowledge entries within a project.
Overview
Memories provide a logical namespace for text content that agents can read and write during generation. Each memory holds many memory entries — individual pieces of text that are automatically embedded for semantic search via the Knowledge module.
Agents can retrieve relevant entries automatically via knowledge_config and write new facts using the built-in write_memory tool. See Agent Integration for details, and the Memory & Knowledge Engine deep dive for how the write, extraction, and retrieval algorithms fit together end to end.
The module follows SOAT's engine & algorithms pattern: the write funnel, embedding, provenance, and invalidation are the engine; the write algorithm and extraction are the algorithms running on it, with their customization seams documented in the deep dive.
See the Permissions Reference for the IAM action strings for this module.
Related Tutorials
- Agent with Persistent Memory - Step 4 (Create a memory)
- Agent with Persistent Memory - Step 5 (Write memory entries)
- Agent with Persistent Memory - Step 10 (Observe the agent writing to memory)
- Agent with Persistent Memory - Step 11 (Enable automatic extraction)
- Agent with Persistent Memory - Step 13 (Trace a fact back to the turn that produced it)
Data Model
Memory
| Field | Type | Description |
|---|---|---|
id | string | Public ID (mem_ prefix) |
project_id | string | ID of the owning project |
name | string | Human-readable name |
description | string | null | Optional description |
tags | string[] | null | Optional labels for filtering by category |
created_at | string | ISO 8601 creation timestamp |
updated_at | string | ISO 8601 last-updated timestamp |
Memory Entry
Memory entries are the individual knowledge items stored inside a memory. When an entry is created or updated, its content is automatically embedded for semantic similarity search.
| Field | Type | Description |
|---|---|---|
id | string | Public ID (mem_entry_ prefix) |
memory_id | string | ID of the parent memory |
content | string | Text content of the entry |
source_type | string | How the entry was created: manual (default), agent, extraction, or orchestration |
tags | string[] | null | Per-entry labels for entry-granularity tag filtering in Knowledge search |
metadata | object | null | Arbitrary structured metadata attached to the entry |
source_generation_id | string | null | The generation whose turn produced the entry — see Provenance |
source_conversation_id | string | null | The conversation the producing turn belonged to — see Provenance |
invalidated_at | string | null | When the entry was superseded; null means currently valid — see Temporal invalidation |
superseded_by_entry_id | string | null | The entry that replaced this one, when superseded |
created_at | string | ISO 8601 creation timestamp |
updated_at | string | ISO 8601 last-updated timestamp |
Key Concepts
What belongs in a memory
A memory entry is a fact the agent learns about the world — a customer's shipping address, a decision a team reached, a constraint discovered while working. It is retrieved by semantic similarity and consumed as context.
A correction to the agent's behavior is not a fact, and does not belong here. "Never quote a delivery date without checking stock" is doctrine about how the agent should act; storing it as an entry makes its application depend on whether a retrieval happened to rank it highly. Doctrine has two durable homes instead:
- A constraint that must never be violated — a guardrail
deny, which refuses the action deterministically rather than hoping the model reads the entry. - Guidance the model should follow — the agent's
instructions, which agent versions archive on every write, so the change is attributable and reversible.
When the same correction keeps being made by hand, the approvals recurrence view is what surfaces it.
Write Algorithm
Every write to a memory — via REST, agent tool, or extraction — goes through the same deduplication algorithm.
When you call POST /api/v1/memory-entries (with memory_id in the body), the server:
- Embeds the incoming content.
- Finds the most similar currently-valid existing entry in that memory (cosine similarity via pgvector). Invalidated entries are never candidates.
- Decides based on two configurable thresholds:
| Similarity range | Decision | What happens |
|---|---|---|
≥ duplicate_threshold | Skip | The fact is already known. Returns the existing entry unchanged. |
| below it | Create | A new entry is written. |
duplicate_threshold is a per-request field on POST /api/v1/memory-entries, defaulting to 0.95.
Merge is a third outcome, and only agent write paths can reach it. A write made
during a generation (the write_memory tool and
automatic extraction) carries an agent context, so a fact
that is merely similar to an existing entry — scoring at or above 0.75 but
below duplicate_threshold — is consolidated with it into a single atomic
fact by the agent's LLM, contradictions resolving in favour of the new fact.
A write with no agent context — the manual endpoint above and the
orchestration memory_write node — has no
model to consolidate with, so it creates instead. Consolidation is also
best-effort on the agent paths: if the completion fails or comes back empty, the
write creates too. Nothing is ever appended to an existing entry, so no write can
lose a fact, and an entry stays one fact rather than growing into a paragraph
whose embedding drifts away from everything in it. The cost is a possible
near-duplicate pair, which future arbitration merges properly.
On a merge, the incoming tags are unioned into the existing entry's tags and metadata is shallow-merged (incoming keys win), so accumulated labels are never lost. PUT /api/v1/memory-entries/:id replaces tags/metadata outright; pass null (or [] for tags) to clear.
Response action Field
The response always includes an action field alongside the entry:
action | HTTP status | Meaning |
|---|---|---|
created | 201 | New entry written |
updated | 200 | Existing entry rewritten to absorb the incoming fact. Agent write paths only — the manual endpoint never returns it |
skipped | 200 | Duplicate detected — existing entry returned |
superseded | 200 | The incoming fact contradicted an existing entry, which was invalidated and replaced. Produced by the LLM-arbitrated write path, which has not shipped yet — the value is part of the API contract so clients can handle it from day one. |
Provenance
Entries written during a generation record where the fact came from, so "why does the agent believe this" is answerable from the entry itself:
| Written by | source_generation_id | source_conversation_id |
|---|---|---|
write_memory tool | the generation that called the tool | null — the tool has no conversation context |
| Automatic extraction | the generation whose turn was extracted | the conversation, when the turn came from one |
POST /api/v1/memory-entries | null | null |
Orchestration memory_write node | null | null |
Provenance is recorded when the entry is created and never rewritten by a later merge: it names the turn that first asserted the fact. A later turn that genuinely replaces the fact supersedes it with a new entry, which carries its own provenance.
Both fields are null when the referenced generation or conversation is deleted — removing
a conversation never deletes the facts learned from it.
See it end to end in Agent with Persistent Memory - Step 13 (Trace a fact back to the turn that produced it).
Temporal invalidation
An entry that no longer holds is retired rather than rewritten. Superseding sets
invalidated_at and points superseded_by_entry_id at the replacement, so the history
stays intact: DELETE remains the way to remove an entry outright.
Invalidated entries are excluded from:
- entry listing (
GET /api/v1/memory-entries) unlessinclude_invalidated=trueis passed - write deduplication — a retired fact is never a merge target, so restating superseded knowledge creates a new entry
- Knowledge search, so a retired fact is never injected into a generation
They stay readable by ID (GET /api/v1/memory-entries/{entry_id}) for audit.
The write path that produces an invalidation — LLM arbitration over a shortlist of similar entries — has not shipped yet; the columns and the API shape are in place because supersede history cannot be reconstructed after the fact.
Tag Filtering
Tags are free-form strings attached to a memory at creation or update time.
POST /api/v1/memories
{
"project_id": "proj_abc",
"name": "Customer Preferences",
"tags": ["customer", "crm", "user-prefs"]
}
Use the tags query parameter on GET /api/v1/memories to filter. The parameter supports glob patterns:
| Pattern | Matches |
|---|---|
crm | Only crm (exact) |
customer* | customer, customer-support, customer-prefs |
user-?refs | user-prefs, user-xrefs, etc. |
Multiple patterns are ORed — a memory is included if any of its tags match any pattern. The same glob syntax applies to memory_tags in Knowledge search.
Entry-Level Tag Filtering
Memory entries carry their own tags (and optional metadata), independent of the container's tags. memory_tags in Knowledge search and an agent's knowledge_config.memory_tags match at entry granularity: an entry is returned when either its parent memory's tags match the globs (container-level, all entries returned) or the entry's own tags match (only that entry returned). This lets a single memory hold entries for many roles/sources and retrieve just the relevant slice — e.g. tag captured rules with role:traffic-manager and source:rejected_approval, then search memory_tags: ["role:traffic-manager"] to read only those.
soat create-memory-entry \
--memory-id mem_01 \
--content "Reject refunds above $500 for the traffic-manager role" \
--tags '["role:traffic-manager", "source:rejected_approval"]' \
--metadata '{"evidence": "high"}'
Orchestration memory_write Node
The orchestration memory_write node maps its input_mapping into a memory-entry write. Besides content, the node honors:
tags— either a string array, or a{ key: value }mapping that is flattened intokey:valuetag strings (sotags: { role: "traffic-manager" }becomes["role:traffic-manager"]).metadata— a plain object stored on the entry.source_type— honored when supplied; defaults toorchestrationfor node-written entries.
Agent Integration
Agents can read from and write to memories automatically during generation.
Automatic Knowledge Retrieval
Set knowledge_config on an agent to have the server search relevant memory entries before every generation and inject them as a delimited reference-context message (never as system content, since memory entries can be user-derived). See Knowledge Config in the Agents module.
write_memory Tool
Set write_memory_id in the agent's knowledge_config to automatically inject a write_memory tool into every generation. The tool accepts a single content input — the atomic fact to write. The target memory is fixed by write_memory_id; the agent cannot choose a different memory. Entries written by the tool are tagged with source_type: "agent".
{
"knowledge_config": {
"memory_ids": ["mem_alice"],
"write_memory_id": "mem_alice"
}
}
Automatic Extraction
Set extraction alongside write_memory_id to have the server extract facts from completed generation turns automatically — no explicit write_memory call by the agent is needed. Pass true for the defaults, or an object to customize the provider, model, and prompt used for extraction:
{
"knowledge_config": {
"write_memory_id": "mem_alice",
"extraction": true
}
}
How it works:
- After a conversation, session, or direct agent generation completes, the server runs a fire-and-forget extraction step. It never blocks or fails the generation response.
- The extraction step sends the turn's transcript as a plain completion (no tools, no knowledge injection) and asks for a JSON array of atomic facts. Transient content such as greetings is skipped.
- Each candidate fact (at most 20 per turn) goes through the standard write algorithm — duplicates are skipped, related facts are merged. Entries are tagged with
source_type: "extraction". - A summary (
{ candidates, created, updated, skipped }) is recorded on the originating generation'sextractionfield for observability via the Generations API.
Object form fields (all optional):
| Field | Default | Description |
|---|---|---|
enabled | true | Set false to keep the configuration but disable extraction |
ai_provider_id | agent's provider | Provider override for extraction calls — must belong to the agent's project |
model | see below | Model override for extraction calls |
prompt | built-in instructions | Replaces the default task instructions; the JSON response contract and the transcript are always appended |
Provider resolution order: extraction.ai_provider_id → the agent's pinned provider → the agent's model_route_id → the project's default_model_route_id. Model resolution for the provider cases: extraction.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 switches the fallback 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 extraction.model does not apply), the extraction call gets ordered provider failover, and it is metered against the target that actually served.
The custom prompt controls what to extract, not the response format — the server always appends the JSON-array contract line and the conversation transcript, since the extraction parser accepts nothing else.
Extraction is opt-in and requires both fields: extraction without write_memory_id does nothing. Streaming generations and requires_action (client-tool) turns do not trigger extraction; the turn must complete in the same request.
Gating extraction per turn
The agent-level extraction flag decides the default, but a single POST /agents/:id/generate call can override it with a top-level extract boolean (not inside knowledge_config):
extractomitted — follow the agent's storedextractiondefault.extract: false— suppress extraction for this turn even when the agent enables it. Use this for operational or tool-listing turns whose facts would only add noise to a curated memory.extract: true— force extraction for this turn even when the agent does not enable it by default, provided the agent has awrite_memory_id.
The extract flag has no effect on streaming or requires_action turns (they never extract), and cannot conjure a target: extract: true is still a no-op when the agent has no write_memory_id.
Extraction reads the agent's stored knowledge_config at generation time and normalizes its casing on read, so an agent deployed by a Formation (whose stored config may be snake_case) extracts correctly without needing to be re-saved.
See it end to end in Agent with Persistent Memory - Step 11 (Enable automatic extraction).
Examples
Create a memory
- CLI
- SDK
- curl
soat create-memory \
--project-id proj_ABC \
--name "Customer Preferences" \
--tags '["customer", "crm"]'
import { SoatClient } from '@soat/sdk';
const soat = new SoatClient({ baseUrl: 'https://api.example.com', token: 'sk_...' });
const { data, error } = await soat.memories.createMemory({
body: {
project_id: 'proj_ABC',
name: 'Customer Preferences',
tags: ['customer', 'crm'],
},
});
if (error) throw new Error(JSON.stringify(error));
curl -X POST https://api.example.com/api/v1/memories \
-H "Authorization: Bearer <token>" \
-H "Content-Type: application/json" \
-d '{
"project_id": "proj_ABC",
"name": "Customer Preferences",
"tags": ["customer", "crm"]
}'
Write a memory entry
- CLI
- SDK
- curl
soat create-memory-entry \
--memory-id mem_01 \
--content "Customer prefers email over phone calls"
const { data, error } = await soat.memories.createMemoryEntry({
body: { memory_id: 'mem_01', content: 'Customer prefers email over phone calls' },
});
if (error) throw new Error(JSON.stringify(error));
// data.action is "created", "updated", or "skipped"
curl -X POST https://api.example.com/api/v1/memory-entries \
-H "Authorization: Bearer <token>" \
-H "Content-Type: application/json" \
-d '{"memory_id": "mem_01", "content": "Customer prefers email over phone calls"}'