Agent with Persistent Memory
This tutorial gives an agent long-term memory that persists across sessions: you
create a Memory, write entries and observe
the deduplication outcomes, combine memory with a
Document via knowledge_config, let the agent
write facts back with write_memory_id, enable automatic extraction, and query the
knowledge layer directly.
Prerequisites
- SOAT running locally. Follow the Quick Start guide to bring the stack up with Docker Compose.
- New to SOAT? Read Key Concepts to understand projects, agents, and the IAM model before diving in.
- CLI installed and configured, or SDK set up. See CLI or SDK.
- For production hardening (secrets, env vars), see Configuration.
- Server is at
http://localhost:5047. - Ollama running locally with a chat model available.
- CLI
- SDK
- curl
export SOAT_BASE_URL=http://localhost:5047
All code snippets below use a SoatClient instance created in Step 1. Memory and knowledge operations use the static SDK classes Memories and MemoryEntries imported from @soat/sdk.
import {
SoatClient,
createClient,
createConfig,
Memories,
MemoryEntries,
} from '@soat/sdk';
export SOAT_URL=http://localhost:5047
Step 1 — Log in as admin
Admin is the built-in superuser role. It bypasses policy evaluation entirely. See Users for full authentication details.
- CLI
- SDK
- curl
ADMIN_TOKEN=$(soat login-user --username admin --password Admin1234! | jq -r '.token')
export SOAT_TOKEN=$ADMIN_TOKEN
const soat = new SoatClient({ baseUrl: 'http://localhost:5047' });
const { data: login } = await soat.users.loginUser({
body: { username: 'admin', password: 'Admin1234!' },
});
const ADMIN_TOKEN = login.token;
// Standard resources (projects, agents, AI providers) via SoatClient
const adminSoat = new SoatClient({
baseUrl: 'http://localhost:5047',
token: ADMIN_TOKEN,
});
// Memories and MemoryEntries use static SDK classes with an explicit client
const authClient = createClient(
createConfig({
baseUrl: 'http://localhost:5047',
headers: { Authorization: `Bearer ${ADMIN_TOKEN}` },
})
);
ADMIN_TOKEN=$(curl -s -X POST "$SOAT_URL/api/v1/users/login" \
-H "Content-Type: application/json" \
-d '{"username":"admin","password":"Admin1234!"}' | jq -r '.token')
Step 2 — Create a project
Every resource in SOAT lives inside a project. Create one to hold the memory and agent.
- CLI
- SDK
- curl
PROJECT_ID=$(soat create-project --name "Support Demo" | jq -r '.id')
echo "PROJECT_ID: $PROJECT_ID"
const { data: project } = await adminSoat.projects.createProject({
body: { name: 'Support Demo' },
});
const PROJECT_ID = project.id;
PROJECT_ID=$(curl -s -X POST "$SOAT_URL/api/v1/projects" \
-H "Authorization: Bearer $ADMIN_TOKEN" \
-H "Content-Type: application/json" \
-d '{"name":"Support Demo"}' | jq -r '.id')
echo "PROJECT_ID: $PROJECT_ID"
Step 3 — Create an AI provider
Set up a local AI provider backed by Ollama. This tutorial uses a local Ollama provider so it can run without external credentials. To connect xAI, OpenAI, Anthropic, or Amazon Bedrock instead, see Connect Third-Party LLMs.
- CLI
- SDK
- curl
AI_PROVIDER_ID=$(soat create-ai-provider \
--project-id "$PROJECT_ID" \
--name "Local Ollama" \
--provider "ollama" \
--default-model "qwen2.5:0.5b" | jq -r '.id')
echo "AI_PROVIDER_ID: $AI_PROVIDER_ID"
const { data: aiProvider } = await adminSoat.aiProviders.createAiProvider({
body: {
project_id: PROJECT_ID,
name: 'Local Ollama',
provider: 'ollama',
default_model: 'qwen2.5:0.5b',
},
});
const AI_PROVIDER_ID = aiProvider.id;
AI_PROVIDER_ID=$(curl -s -X POST "$SOAT_URL/api/v1/ai-providers" \
-H "Authorization: Bearer $ADMIN_TOKEN" \
-H "Content-Type: application/json" \
-d "{\"project_id\":\"$PROJECT_ID\",\"name\":\"Local Ollama\",\"provider\":\"ollama\",\"default_model\":\"qwen2.5:0.5b\"}" \
| jq -r '.id')
echo "AI_PROVIDER_ID: $AI_PROVIDER_ID"
Step 4 — Create a memory
A Memory is a named container that holds a collection of text entries. You can attach tags to a memory for later filtering — useful when an agent should search only a subset of all memories in a project.
- CLI
- SDK
- curl
MEMORY_ID=$(soat create-memory \
--project-id "$PROJECT_ID" \
--name "Alice Profile" \
--description "Facts about customer Alice gathered during support interactions" \
--tags '["alice","customer"]' | jq -r '.id')
echo "MEMORY_ID: $MEMORY_ID"
const { data: memory } = await Memories.createMemory({
client: authClient,
body: {
project_id: PROJECT_ID,
name: 'Alice Profile',
description:
'Facts about customer Alice gathered during support interactions',
tags: ['alice', 'customer'],
},
});
const MEMORY_ID = memory.id;
MEMORY_ID=$(curl -s -X POST "$SOAT_URL/api/v1/memories" \
-H "Authorization: Bearer $ADMIN_TOKEN" \
-H "Content-Type: application/json" \
-d "{\"project_id\":\"$PROJECT_ID\",\"name\":\"Alice Profile\",\"description\":\"Facts about customer Alice gathered during support interactions\",\"tags\":[\"alice\",\"customer\"]}" \
| jq -r '.id')
echo "MEMORY_ID: $MEMORY_ID"
Step 5 — Write memory entries
Every write goes through the semantic deduplication described in
Memories — Write Algorithm. A manual write has
no agent context, so it produces one of two outcomes: created (201, the fact is
stored as its own entry) or skipped (200, a near-identical entry already exists).
The third outcome — updated, where an existing entry is rewritten to absorb the
incoming fact — needs a model to consolidate the two, so only the agent write paths reach
it. Step 10 shows it on the write_memory
tool.
5a — First entry (action: created)
A genuinely new fact. No similar entry exists, so it is stored.
- CLI
- SDK
- curl
soat create-memory-entry \
--memory-id "$MEMORY_ID" \
--content "Alice prefers email over phone calls for all support communication"
# → { "action": "created", ... }
const { data: e1 } = await MemoryEntries.createMemoryEntry({
client: authClient,
body: {
memory_id: MEMORY_ID,
content:
'Alice prefers email over phone calls for all support communication',
},
});
console.log(e1.action); // "created"
curl -s -X POST "$SOAT_URL/api/v1/memory-entries" \
-H "Authorization: Bearer $ADMIN_TOKEN" \
-H "Content-Type: application/json" \
-d '{"memory_id":"'"$MEMORY_ID"'","content":"Alice prefers email over phone calls for all support communication"}' | jq .
# → { "action": "created", ... }
5b — Near-duplicate (action: skipped)
Almost identical to 5a, so the write is ignored.
- CLI
- SDK
- curl
soat create-memory-entry \
--memory-id "$MEMORY_ID" \
--content "Alice prefers email over phone calls"
# → { "action": "skipped", ... }
const { data: e2 } = await MemoryEntries.createMemoryEntry({
client: authClient,
body: {
memory_id: MEMORY_ID,
content: 'Alice prefers email over phone calls',
},
});
console.log(e2.action); // "skipped"
curl -s -X POST "$SOAT_URL/api/v1/memory-entries" \
-H "Authorization: Bearer $ADMIN_TOKEN" \
-H "Content-Type: application/json" \
-d '{"memory_id":"'"$MEMORY_ID"'","content":"Alice prefers email over phone calls"}' | jq .
# → { "action": "skipped", ... }
5c — Related content (action: created)
Overlapping content with new detail. There is no model on this path to fold the two facts into one, so the richer statement is stored as its own entry rather than being appended to 5a — entries stay atomic.
- CLI
- SDK
- curl
soat create-memory-entry \
--memory-id "$MEMORY_ID" \
--content "Alice prefers email, especially for billing inquiries; she checks it twice a day"
# → { "action": "created", ... }
const { data: e3 } = await MemoryEntries.createMemoryEntry({
client: authClient,
body: {
memory_id: MEMORY_ID,
content:
'Alice prefers email, especially for billing inquiries; she checks it twice a day',
},
});
console.log(e3.action); // "created"
curl -s -X POST "$SOAT_URL/api/v1/memory-entries" \
-H "Authorization: Bearer $ADMIN_TOKEN" \
-H "Content-Type: application/json" \
-d '{"memory_id":"'"$MEMORY_ID"'","content":"Alice prefers email, especially for billing inquiries; she checks it twice a day"}' | jq .
# → { "action": "created", ... }
5d — Second distinct fact (action: created)
An unrelated fact is stored as a new entry.
- CLI
- SDK
- curl
soat create-memory-entry \
--memory-id "$MEMORY_ID" \
--content "The Alice Corp fiscal year ends in March; she starts renewal discussions in January"
# → { "action": "created", ... }
const { data: e4 } = await MemoryEntries.createMemoryEntry({
client: authClient,
body: {
memory_id: MEMORY_ID,
content:
'The Alice Corp fiscal year ends in March; she starts renewal discussions in January',
},
});
console.log(e4.action); // "created"
curl -s -X POST "$SOAT_URL/api/v1/memory-entries" \
-H "Authorization: Bearer $ADMIN_TOKEN" \
-H "Content-Type: application/json" \
-d '{"memory_id":"'"$MEMORY_ID"'","content":"The Alice Corp fiscal year ends in March; she starts renewal discussions in January"}' | jq .
# → { "action": "created", ... }
Step 6 — List entries to verify
After the four writes, the memory holds exactly three entries — only the near-duplicate from 5b was discarded.
- CLI
- SDK
- curl
soat list-memory-entries --memory-id "$MEMORY_ID" | jq '[.data[] | .content]'
# [
# "Alice prefers email over phone calls for all support communication",
# "Alice prefers email, especially for billing inquiries; she checks it twice a day",
# "The Alice Corp fiscal year ends in March; she starts renewal discussions in January"
# ]
const { data: page } = await MemoryEntries.listMemoryEntries({
client: authClient,
query: { memory_id: MEMORY_ID },
});
console.log(page.data.map((e) => e.content));
curl -s "$SOAT_URL/api/v1/memory-entries?memory_id=$MEMORY_ID" \
-H "Authorization: Bearer $ADMIN_TOKEN" | jq '[.data[] | .content]'
Step 7 — Upload a support-policy document
Store Alice's support policy as a Document. The
path /alice/support-policy.txt lets us later filter the whole /alice/ subtree
with a single document_paths prefix.
- CLI
- SDK
- curl
DOC_ID=$(soat create-document \
--project-id "$PROJECT_ID" \
--path "/alice/support-policy.txt" \
--content "Alice Corp Support Policy: All priority-1 incidents must receive an initial response within 2 hours. Priority-2 incidents within 8 hours. Refunds are approved automatically for outages exceeding 4 hours. Alice Corp is entitled to a dedicated support engineer during business hours (9 AM–6 PM EST)." \
| jq -r '.id')
echo "DOC_ID: $DOC_ID"
const { data: doc } = await adminSoat.documents.createDocument({
body: {
project_id: PROJECT_ID,
path: '/alice/support-policy.txt',
content:
'Alice Corp Support Policy: All priority-1 incidents must receive an initial response within 2 hours. Priority-2 incidents within 8 hours. Refunds are approved automatically for outages exceeding 4 hours. Alice Corp is entitled to a dedicated support engineer during business hours (9 AM–6 PM EST).',
},
});
const DOC_ID = doc.id;
DOC_ID=$(curl -s -X POST "$SOAT_URL/api/v1/documents" \
-H "Authorization: Bearer $ADMIN_TOKEN" \
-H "Content-Type: application/json" \
-d "{\"project_id\":\"$PROJECT_ID\",\"path\":\"/alice/support-policy.txt\",\"content\":\"Alice Corp Support Policy: All priority-1 incidents must receive an initial response within 2 hours. Priority-2 incidents within 8 hours. Refunds are approved automatically for outages exceeding 4 hours. Alice Corp is entitled to a dedicated support engineer during business hours (9 AM-6 PM EST).\"}" \
| jq -r '.id')
echo "DOC_ID: $DOC_ID"
Step 8 — Create an agent with knowledge_config
The knowledge_config field on an agent tells SOAT which memories and documents to search before every generation; the query is derived from the last user message automatically. See Agents for the full field list. Here we combine the memory from Step 4 with the document from Step 7, and set write_memory_id so the agent gets a write_memory tool.
- CLI
- SDK
- curl
AGENT_ID=$(soat create-agent \
--project-id "$PROJECT_ID" \
--ai-provider-id "$AI_PROVIDER_ID" \
--name "Support Agent" \
--instructions "You are a helpful customer support assistant. Use the provided knowledge context to answer questions accurately and concisely. When you learn new facts about a customer, use the write_memory tool to persist them." \
--knowledge-config '{"memory_ids":["'"$MEMORY_ID"'"],"document_paths":["/alice/"],"limit":5,"write_memory_id":"'"$MEMORY_ID"'"}' \
| jq -r '.id')
echo "AGENT_ID: $AGENT_ID"
const { data: agent } = await adminSoat.agents.createAgent({
body: {
project_id: PROJECT_ID,
ai_provider_id: AI_PROVIDER_ID,
name: 'Support Agent',
instructions:
'You are a helpful customer support assistant. Use the provided knowledge context to answer questions accurately and concisely.',
knowledge_config: {
memory_ids: [MEMORY_ID],
document_paths: ['/alice/'],
limit: 5,
write_memory_id: MEMORY_ID,
},
},
});
const AGENT_ID = agent.id;
AGENT_ID=$(curl -s -X POST "$SOAT_URL/api/v1/agents" \
-H "Authorization: Bearer $ADMIN_TOKEN" \
-H "Content-Type: application/json" \
-d "{\"project_id\":\"$PROJECT_ID\",\"ai_provider_id\":\"$AI_PROVIDER_ID\",\"name\":\"Support Agent\",\"instructions\":\"You are a helpful customer support assistant. Use the provided knowledge context to answer questions accurately and concisely. When you learn new facts about a customer, use the write_memory tool to persist them.\",\"knowledge_config\":{\"memory_ids\":[\"$MEMORY_ID\"],\"document_paths\":[\"/alice/\"],\"limit\":5,\"write_memory_id\":\"$MEMORY_ID\"}}" \
| jq -r '.id')
echo "AGENT_ID: $AGENT_ID"
Step 9 — Run a generation
Send a user message that requires both customer facts (from memory) and the support policy (from the document). SOAT searches both sources and injects matching results as a fenced reference-context user message before calling the model — never as system content, since retrieved knowledge can be user-derived (see Knowledge Config).
- CLI
- SDK
- curl
soat create-agent-generation --wait true \
--agent-id "$AGENT_ID" \
--messages '[{"role":"user","content":"Alice has a P1 outage since 3 hours ago. How should we handle it and how do we best reach her?"}]' \
| jq '{status: .status, output: .output.content}'
Expected shape:
{
"status": "completed",
"output": "Since Alice has a P1 outage, an initial response should have been sent within 2 hours per the support policy ... Contact her by email, which she checks twice a day and prefers for all support communication ..."
}
The model combines facts from memory (email preference) and the document (2-hour P1 response) — neither appeared in the user message.
const { data: generation } = await adminSoat.agents.createAgentGeneration({
path: { agent_id: AGENT_ID },
query: { wait: true },
body: {
messages: [
{
role: 'user',
content:
'Alice has a P1 outage since 3 hours ago. How should we handle it and how do we best reach her?',
},
],
},
});
console.log(generation.status); // "completed"
console.log(generation.output.content);
// e.g. "P1 SLA requires a response within 2 hours ... reach Alice by email ..."
curl -s -X POST "$SOAT_URL/api/v1/agents/$AGENT_ID/generate?wait=true" \
-H "Authorization: Bearer $ADMIN_TOKEN" \
-H "Content-Type: application/json" \
-d '{"messages":[{"role":"user","content":"Alice has a P1 outage since 3 hours ago. How should we handle it and how do we best reach her?"}]}' \
| jq '{status: .status, output: .output.content}'
Step 10 — Observe the agent writing to memory
If the model decides to call the write_memory tool, the fact is persisted via the same deduplication algorithm as manual writes — with one addition. This path has an agent context, so a fact that overlaps an existing entry is consolidated with it into a single atomic fact by the agent's LLM and comes back as action: "updated", instead of landing as a second entry the way 5c did. Send a message that introduces a new fact:
- CLI
- SDK
- curl
soat create-agent-generation --wait true \
--agent-id "$AGENT_ID" \
--messages '[{"role":"user","content":"Just so you know, Alice moved to the West Coast and is now in the PT timezone."}]' \
| jq '{status: .status, output: .output.content}'
const { data: gen2 } = await adminSoat.agents.createAgentGeneration({
path: { agent_id: AGENT_ID },
query: { wait: true },
body: {
messages: [
{
role: 'user',
content:
'Just so you know, Alice moved to the West Coast and is now in the PT timezone.',
},
],
},
});
console.log(gen2.status); // "completed"
curl -s -X POST "$SOAT_URL/api/v1/agents/$AGENT_ID/generate?wait=true" \
-H "Authorization: Bearer $ADMIN_TOKEN" \
-H "Content-Type: application/json" \
-d '{"messages":[{"role":"user","content":"Just so you know, Alice moved to the West Coast and is now in the PT timezone."}]}' \
| jq '{status: .status, output: .output.content}'
After the generation completes, list the memory entries and look for any with source_type == "agent". Entries written during a generation also carry provenance — the id of the turn that produced them:
- CLI
- SDK
- curl
soat list-memory-entries --memory-id "$MEMORY_ID" \
| jq '[.data[] | select(.source_type == "agent")
| {content, source_type, source_generation_id}]'
const { data: page } = await MemoryEntries.listMemoryEntries({
client: authClient,
query: { memory_id: MEMORY_ID },
});
const agentEntries = page.data.filter((e) => e.source_type === 'agent');
console.log(agentEntries.map((e) => [e.content, e.source_generation_id]));
curl -s "$SOAT_URL/api/v1/memory-entries?memory_id=$MEMORY_ID" \
-H "Authorization: Bearer $ADMIN_TOKEN" \
| jq '[.data[] | select(.source_type == "agent")
| {content, source_type, source_generation_id}]'
If the model called write_memory, you will see an entry with "source_type": "agent" containing the timezone fact, and a source_generation_id pointing at the generation that wrote it. If the list comes back empty the model simply chose not to call the tool this turn — Step 11 removes that dependency.
Step 11 — Enable automatic extraction
The write_memory tool depends on the model deciding to call it. Automatic extraction removes that dependency: after every completed turn the server extracts atomic facts from the transcript and writes them with source: "extraction". Enable it by adding extraction to the agent's knowledge_config:
- CLI
- SDK
- curl
soat update-agent \
--agent-id "$AGENT_ID" \
--knowledge-config '{"memory_ids":["'"$MEMORY_ID"'"],"document_paths":["/alice/"],"limit":5,"write_memory_id":"'"$MEMORY_ID"'","extraction":true}'
await adminSoat.agents.updateAgent({
path: { agent_id: AGENT_ID },
body: {
knowledge_config: {
memory_ids: [MEMORY_ID],
document_paths: ['/alice/'],
limit: 5,
write_memory_id: MEMORY_ID,
extraction: true,
},
},
});
curl -s -X PUT "$SOAT_URL/api/v1/agents/$AGENT_ID" \
-H "Authorization: Bearer $ADMIN_TOKEN" \
-H "Content-Type: application/json" \
-d "{\"knowledge_config\":{\"memory_ids\":[\"$MEMORY_ID\"],\"document_paths\":[\"/alice/\"],\"limit\":5,\"write_memory_id\":\"$MEMORY_ID\",\"extraction\":true}}" \
| jq '.knowledge_config'
extraction: true uses the agent's own provider and model with a built-in prompt; the object form customizes provider, model, and prompt — useful for running extraction on a cheaper model.
Now send a message that reveals a new fact, without asking the agent to remember anything:
- CLI
- SDK
- curl
soat create-agent-generation --wait true \
--agent-id "$AGENT_ID" \
--messages '[{"role":"user","content":"By the way, Alice signed a 2-year contract renewal last week."}]' \
| jq '{status: .status}'
Extraction runs asynchronously after the generation response returns — give it a few seconds, then list the extracted entries:
sleep 5
soat list-memory-entries --memory-id "$MEMORY_ID" \
| jq '[.data[] | select(.source_type == "extraction")
| {content, source_type, source_generation_id}]'
await adminSoat.agents.createAgentGeneration({
path: { agent_id: AGENT_ID },
query: { wait: true },
body: {
messages: [
{
role: 'user',
content:
'By the way, Alice signed a 2-year contract renewal last week.',
},
],
},
});
// Extraction runs asynchronously after the generation response returns.
await new Promise((resolve) => setTimeout(resolve, 5000));
const { data: page } = await MemoryEntries.listMemoryEntries({
client: authClient,
query: { memory_id: MEMORY_ID },
});
const extracted = page.data.filter((e) => e.source_type === 'extraction');
console.log(extracted.map((e) => e.content));
curl -s -X POST "$SOAT_URL/api/v1/agents/$AGENT_ID/generate?wait=true" \
-H "Authorization: Bearer $ADMIN_TOKEN" \
-H "Content-Type: application/json" \
-d '{"messages":[{"role":"user","content":"By the way, Alice signed a 2-year contract renewal last week."}]}' \
| jq '{status: .status}'
sleep 5
curl -s "$SOAT_URL/api/v1/memory-entries?memory_id=$MEMORY_ID" \
-H "Authorization: Bearer $ADMIN_TOKEN" \
| jq '[.data[] | select(.source_type == "extraction")
| {content, source_type, source_generation_id}]'
You should see an entry like "Alice signed a 2-year contract renewal" with "source_type": "extraction" — captured without the model choosing to call a tool. The extraction summary is recorded on the generation's extraction field (Generations).
Step 12 — Query the knowledge layer directly
The Knowledge endpoint is the same search layer the agent uses internally. Pass both memory_ids and document_paths to see exactly which chunks — from both sources — would be injected for a given question.
- CLI
- SDK
- curl
soat search-knowledge \
--project-id "$PROJECT_ID" \
--query "P1 outage response and how to reach Alice" \
--memory-ids '["'"$MEMORY_ID"'"]' \
--document-paths '["/alice/"]' \
| jq '.results[] | {score, similarity_score, source_type, content}'
Expected output — note the two different source_type values:
{ "score": 0.69, "similarity_score": 0.69, "source_type": "document", "content": "Alice Corp Support Policy: All priority-1 incidents must receive an initial response within 2 hours ..." }
{ "score": 0.62, "similarity_score": 0.62, "source_type": "memory", "content": "Alice prefers email, especially for billing inquiries; she checks it twice a day" }
{ "score": 0.50, "similarity_score": 0.50, "source_type": "memory", "content": "The Alice Corp fiscal year ends in March; she starts renewal discussions in January" }
Two scores come back, and they are different contracts — see Relevance scoring:
scoreis the relevance ranking. Results are ordered by it andmin_scorefilters on it. It is implementation-defined: the ordering is the contract, the number is not. Tunemin_scoreagainst it for this deployment, and re-tune after an upgrade rather than treating a value as portable.similarity_scoreis the raw cosine similarity, pinned to that meaning. Read it when you need a stable number to compare or log.
They are equal here because the ranking is currently single-signal.
const res = await fetch('http://localhost:5047/api/v1/knowledge/search', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${ADMIN_TOKEN}`,
},
body: JSON.stringify({
project_id: PROJECT_ID,
query: 'P1 outage response and how to reach Alice',
memory_ids: [MEMORY_ID],
document_paths: ['/alice/'],
}),
});
const { results } = await res.json();
results.forEach((r) =>
console.log(r.score, r.similarity_score, r.source_type, r.content)
);
curl -s -X POST "$SOAT_URL/api/v1/knowledge/search" \
-H "Authorization: Bearer $ADMIN_TOKEN" \
-H "Content-Type: application/json" \
-d "{\"project_id\":\"$PROJECT_ID\",\"query\":\"P1 outage response and how to reach Alice\",\"memory_ids\":[\"$MEMORY_ID\"],\"document_paths\":[\"/alice/\"]}" \
| jq '.results[] | {score, similarity_score, source_type, content}'
Step 13 — Trace a fact back to the turn that produced it
Retrieved memory shapes what the agent says, so "why does it believe this?" has to be answerable. Every entry written during a generation records provenance: the generation, and the conversation when the turn came from one.
Manual writes have no turn behind them, so the contrast is visible in one listing — the entries from Step 5 carry null, while anything the write_memory tool or extraction wrote carries an id:
- CLI
- SDK
- curl
soat list-memory-entries --memory-id "$MEMORY_ID" \
| jq '[.data[] | {source_type, source_generation_id, source_conversation_id}]'
[
{
"source_type": "manual",
"source_generation_id": null,
"source_conversation_id": null
},
{
"source_type": "manual",
"source_generation_id": null,
"source_conversation_id": null
},
{
"source_type": "manual",
"source_generation_id": null,
"source_conversation_id": null
},
{
"source_type": "extraction",
"source_generation_id": "gen_0dR2mJk8xQ1vTbLp",
"source_conversation_id": null
}
]
source_conversation_id is null above because this tutorial drives the agent with create-agent-generation, which has no conversation. Drive the same agent through Conversations and extraction records both.
Follow a provenance id to the generation itself:
GEN_ID=$(soat list-memory-entries --memory-id "$MEMORY_ID" \
| jq -r '[.data[] | select(.source_generation_id != null)][0].source_generation_id // empty')
# → ignore
soat get-generation --generation-id "$GEN_ID" | jq '{id, status, extraction}'
The second command is annotated ignore because it only has an id to look up if the model actually wrote to memory on this run — the point it demonstrates does not survive being made mandatory. See Generations for the full record, including the extraction summary of what that turn contributed.
const { data: page } = await MemoryEntries.listMemoryEntries({
client: authClient,
query: { memory_id: MEMORY_ID },
});
page.data.forEach((e) =>
console.log(e.source_type, e.source_generation_id, e.source_conversation_id)
);
const traced = page.data.find((e) => e.source_generation_id);
if (traced) {
const { data: generation } = await adminSoat.generations.getGeneration({
path: { generation_id: traced.source_generation_id },
});
console.log(generation.id, generation.status);
}
curl -s "$SOAT_URL/api/v1/memory-entries?memory_id=$MEMORY_ID" \
-H "Authorization: Bearer $ADMIN_TOKEN" \
| jq '[.data[] | {source_type, source_generation_id, source_conversation_id}]'
GEN_ID=$(curl -s "$SOAT_URL/api/v1/memory-entries?memory_id=$MEMORY_ID" \
-H "Authorization: Bearer $ADMIN_TOKEN" \
| jq -r '[.data[] | select(.source_generation_id != null)][0].source_generation_id // empty')
curl -s "$SOAT_URL/api/v1/generations/$GEN_ID" \
-H "Authorization: Bearer $ADMIN_TOKEN" | jq '{id, status, extraction}'
Provenance is recorded when the entry is created and is never rewritten by a later merge — it names the turn that first asserted the fact. A fact that is later contradicted is retired rather than edited, which keeps the original entry (and its provenance) readable for audit; pass --include-invalidated true to list-memory-entries to see retired entries alongside live ones. See Temporal invalidation.
What's next
- Tag-based filtering — separate memories per customer and
memory_tagson the agent scope retrieval per customer. - Adjust the dedup threshold — tune
duplicate_thresholdto control how close a fact must be before a manual write is skipped; see Memories. - Audit what an agent was told — pair the provenance ids from Step 13 with the injected
<knowledge>block documented in Agents — Knowledge Config, whose source tags name the exact entry and document page behind each retrieved line.