Agent with Persistent Memory
Give an agent memory that persists across sessions: create a
memory store, write memories and observe deduplication,
combine memories with a Document via knowledge_config,
let the agent write back with write_memory_store_id, give the store a memory rule that
ingests finished turns, and query the knowledge layer directly.
Prerequisites
- SOAT running locally at
http://localhost:5047(Quick Start); Key Concepts; Configuration. - CLI or SDK.
- Ollama with a chat model.
- CLI
- SDK
- curl
export SOAT_BASE_URL=http://localhost:5047
SDK snippets use the SoatClient from Step 1; memory and knowledge operations use the static classes MemoryStores and Memories from @soat/sdk.
import {
SoatClient,
createClient,
createConfig,
MemoryStores,
Memories,
} from '@soat/sdk';
export SOAT_URL=http://localhost:5047
Step 1 — Log in as admin
See Users.
- 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,
});
// MemoryStores and Memories 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
A project holds the memory store 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
A local Ollama AI provider. For xAI, OpenAI, Anthropic, or Amazon Bedrock 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 store
A memory store is a named container of memories; key-value tags let an agent search a subset of a project's stores.
- CLI
- SDK
- curl
MEMORY_STORE_ID=$(soat create-memory-store \
--project-id "$PROJECT_ID" \
--name "Alice Profile" \
--description "Facts about customer Alice gathered during support interactions" \
--tags '{"customer":"alice","kind":"profile"}' | jq -r '.id')
echo "MEMORY_STORE_ID: $MEMORY_STORE_ID"
const { data: memoryStore } = await MemoryStores.createMemoryStore({
client: authClient,
body: {
project_id: PROJECT_ID,
name: 'Alice Profile',
description:
'Facts about customer Alice gathered during support interactions',
tags: { customer: 'alice', kind: 'profile' },
},
});
const MEMORY_STORE_ID = memoryStore.id;
MEMORY_STORE_ID=$(curl -s -X POST "$SOAT_URL/api/v1/memory-stores" \
-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\":{\"customer\":\"alice\",\"kind\":\"profile\"}}" \
| jq -r '.id')
echo "MEMORY_STORE_ID: $MEMORY_STORE_ID"
Step 5 — Write memories
Every write goes through the same three-outcome algorithm
(Memories — Write Algorithm), on every path:
created (201, a distinct fact stored as its own memory), skipped (200, the
fact is already known), or superseded (200, the same fact has changed — the old
memory is retired and a new one replaces it). Each write also appends one
assertion, whatever it resolved to; Step 5e reads
them back.
5a — First memory (action: created)
No similar memory exists, so it is stored.
- CLI
- SDK
- curl
soat create-memory \
--memory-store-id "$MEMORY_STORE_ID" \
--content "Alice prefers email over phone calls for all support communication"
# → { "action": "created", ... }
const { data: e1 } = await Memories.createMemory({
client: authClient,
body: {
memory_store_id: MEMORY_STORE_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/memories" \
-H "Authorization: Bearer $ADMIN_TOKEN" \
-H "Content-Type: application/json" \
-d '{"memory_store_id":"'"$MEMORY_STORE_ID"'","content":"Alice prefers email over phone calls for all support communication"}' | jq .
# → { "action": "created", ... }
5b — Near-duplicate (action: skipped)
Near-identical to 5a; ignored.
- CLI
- SDK
- curl
soat create-memory \
--memory-store-id "$MEMORY_STORE_ID" \
--content "Alice prefers email over phone calls"
# → { "action": "skipped", ... }
const { data: e2 } = await Memories.createMemory({
client: authClient,
body: {
memory_store_id: MEMORY_STORE_ID,
content: 'Alice prefers email over phone calls',
},
});
console.log(e2.action); // "skipped"
curl -s -X POST "$SOAT_URL/api/v1/memories" \
-H "Authorization: Bearer $ADMIN_TOKEN" \
-H "Content-Type: application/json" \
-d '{"memory_store_id":"'"$MEMORY_STORE_ID"'","content":"Alice prefers email over phone calls"}' | jq .
# → { "action": "skipped", ... }
5c — Related content (action: created)
Overlaps 5a with new detail, but not closely enough to count as the same fact, so the richer statement is stored as its own atomic memory.
- CLI
- SDK
- curl
soat create-memory \
--memory-store-id "$MEMORY_STORE_ID" \
--content "Alice prefers email, especially for billing inquiries; she checks it twice a day"
# → { "action": "created", ... }
const { data: e3 } = await Memories.createMemory({
client: authClient,
body: {
memory_store_id: MEMORY_STORE_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/memories" \
-H "Authorization: Bearer $ADMIN_TOKEN" \
-H "Content-Type: application/json" \
-d '{"memory_store_id":"'"$MEMORY_STORE_ID"'","content":"Alice prefers email, especially for billing inquiries; she checks it twice a day"}' | jq .
# → { "action": "created", ... }
5d — Second distinct fact (action: created)
Unrelated; stored as a new memory.
- CLI
- SDK
- curl
soat create-memory \
--memory-store-id "$MEMORY_STORE_ID" \
--content "The Alice Corp fiscal year ends in March; she starts renewal discussions in January"
# → { "action": "created", ... }
const { data: e4 } = await Memories.createMemory({
client: authClient,
body: {
memory_store_id: MEMORY_STORE_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/memories" \
-H "Authorization: Bearer $ADMIN_TOKEN" \
-H "Content-Type: application/json" \
-d '{"memory_store_id":"'"$MEMORY_STORE_ID"'","content":"The Alice Corp fiscal year ends in March; she starts renewal discussions in January"}' | jq .
# → { "action": "created", ... }
5e — Read the write ledger
Every write above left an assertion, including 5b, which produced no memory at all. Newest first:
- CLI
- SDK
- curl
soat list-memory-store-assertions --memory-store-id "$MEMORY_STORE_ID" \
| jq '[.data[] | {outcome, mechanism, principal_type, content}]'
const { data: ledger } = await MemoryStores.listMemoryStoreAssertions({
client: authClient,
path: { memory_store_id: MEMORY_STORE_ID },
});
console.log(
ledger.data.map((a) => [a.outcome, a.mechanism, a.principal_type])
);
curl -s "$SOAT_URL/api/v1/memory-stores/$MEMORY_STORE_ID/assertions" \
-H "Authorization: Bearer $ADMIN_TOKEN" \
| jq '[.data[] | {outcome, mechanism, principal_type, content}]'
Four rows for four writes: three created and the skipped from 5b, each mechanism: "api"
because they came through the REST door. content is the text as asserted, so the skipped
row holds what 5b tried to write, not what 5a stored.
Step 6 — List memories to verify
Three memories remain; only 5b was discarded (Memories examples).
- CLI
- SDK
- curl
soat list-memories --memory-store-id "$MEMORY_STORE_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 Memories.listMemories({
client: authClient,
query: { memory_store_id: MEMORY_STORE_ID },
});
console.log(page.data.map((e) => e.content));
curl -s "$SOAT_URL/api/v1/memories?memory_store_id=$MEMORY_STORE_ID" \
-H "Authorization: Bearer $ADMIN_TOKEN" | jq '[.data[] | .content]'
Step 7 — Upload a support-policy document
Store the support policy as a Document. The path
/alice/support-policy.txt lets a single document_paths prefix select the /alice/ subtree.
- 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
knowledge_config on an agent names the memory stores and documents searched before every generation, with the query derived from the last user message. Here it combines the memory store from Step 4 with the document from Step 7; write_memory_store_id gives the agent 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_store_ids":["'"$MEMORY_STORE_ID"'"],"document_paths":["/alice/"],"limit":5,"write_memory_store_id":"'"$MEMORY_STORE_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_store_ids: [MEMORY_STORE_ID],
document_paths: ['/alice/'],
limit: 5,
write_memory_store_id: MEMORY_STORE_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_store_ids\":[\"$MEMORY_STORE_ID\"],\"document_paths\":[\"/alice/\"],\"limit\":5,\"write_memory_store_id\":\"$MEMORY_STORE_ID\"}}" \
| jq -r '.id')
echo "AGENT_ID: $AGENT_ID"
Step 9 — Run a generation
A message needing both customer facts (memory) and the support policy (document). Matches are injected as a fenced reference-context user message, never as system content, since retrieved knowledge can be user-derived (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 reply combines memory (email preference) and the document (2-hour P1 response); neither was 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
A write_memory call goes through exactly the same algorithm as a manual write, on the
store's thresholds — an agent cannot loosen them. What differs is the assertion it records:
mechanism: "tool", the agent as principal, and the generation it happened in. Send a message
with 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}'
List the store's memories and look for the timezone fact. A write_memory call happens inside a generation that may belong to no conversation, so what it writes carries source_type: "manual" — there is no source to name (Provenance):
- CLI
- SDK
- curl
soat list-memories --memory-store-id "$MEMORY_STORE_ID" \
| jq '[.data[] | {content, source_type, source_id}]'
const { data: page } = await Memories.listMemories({
client: authClient,
query: { memory_store_id: MEMORY_STORE_ID },
});
console.log(page.data.map((e) => [e.content, e.source_type, e.source_id]));
curl -s "$SOAT_URL/api/v1/memories?memory_store_id=$MEMORY_STORE_ID" \
-H "Authorization: Bearer $ADMIN_TOKEN" \
| jq '[.data[] | {content, source_type, source_id}]'
If the model called write_memory, one memory holds the timezone fact, reading
"source_type": "manual" with a null source_id. Where it came from is on its assertion
instead — the door, the agent, and the turn:
- CLI
- SDK
- curl
soat list-memory-store-assertions --memory-store-id "$MEMORY_STORE_ID" --mechanism tool \
| jq '[.data[] | {outcome, principal_id, generation_id, content}]'
const { data: toolWrites } = await MemoryStores.listMemoryStoreAssertions({
client: authClient,
path: { memory_store_id: MEMORY_STORE_ID },
query: { mechanism: 'tool' },
});
console.log(toolWrites.data.map((a) => [a.principal_id, a.generation_id]));
curl -s "$SOAT_URL/api/v1/memory-stores/$MEMORY_STORE_ID/assertions?mechanism=tool" \
-H "Authorization: Bearer $ADMIN_TOKEN" \
| jq '[.data[] | {outcome, principal_id, generation_id, content}]'
An empty list means the model did not call the tool; Step 11 removes that dependency.
Step 11 — Add a memory rule
The write_memory tool is a capability grant: the agent may write, if it decides to. What the
store accepts from a finished turn is the store's own policy — a
memory rule. Create one on the store, selecting this
agent's turns, with no handler so the built-in extractor runs:
- CLI
- SDK
- curl
MEMORY_RULE_ID=$(soat create-memory-rule \
--memory-store-id "$MEMORY_STORE_ID" \
--on generations.completed \
--source-agent-ids '["'"$AGENT_ID"'"]' \
| jq -r '.id')
echo "$MEMORY_RULE_ID"
const { data: rule } = await adminSoat.memoryRules.createMemoryRule({
body: {
memory_store_id: MEMORY_STORE_ID,
on: 'generations.completed',
source_agent_ids: [AGENT_ID],
},
});
const MEMORY_RULE_ID = rule.id;
MEMORY_RULE_ID=$(curl -s -X POST "$SOAT_URL/api/v1/memory-rules" \
-H "Authorization: Bearer $ADMIN_TOKEN" \
-H "Content-Type: application/json" \
-d "{\"memory_store_id\":\"$MEMORY_STORE_ID\",\"on\":\"generations.completed\",\"source_agent_ids\":[\"$AGENT_ID\"]}" \
| jq -r '.id')
echo "$MEMORY_RULE_ID"
With no agent_id or tool_id, the rule runs the built-in extractor on the source agent's
provider and model, with a built-in prompt; prompt, ai_provider_id and model retune it
(e.g. a cheaper model), and an agent_id or tool_id handler
replaces the algorithm outright. Leaving source_agent_ids out would read every agent in
the project.
Send a message revealing a new fact without asking the agent to remember it:
- 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}'
The rule fires asynchronously after the turn completes; wait a few seconds, then list:
sleep 5
soat list-memories --memory-store-id "$MEMORY_STORE_ID" \
| jq '[.data[] | {content, source_type, source_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.',
},
],
},
});
// The rule fires asynchronously after the turn completes.
await new Promise((resolve) => setTimeout(resolve, 5000));
const { data: page } = await Memories.listMemories({
client: authClient,
query: { memory_store_id: MEMORY_STORE_ID },
});
console.log(page.data.map((e) => [e.content, e.source_type, e.source_id]));
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/memories?memory_store_id=$MEMORY_STORE_ID" \
-H "Authorization: Bearer $ADMIN_TOKEN" \
| jq '[.data[] | {content, source_type, source_id}]'
Expect a memory like "Alice signed a 2-year contract renewal". This turn is a direct generation with no conversation behind it, so it reads "source_type": "manual"; the same turn inside a conversation would read "conversation" and name it in source_id. What the rule wrote is recorded on the generation's extraction field, keyed by the rule's id (Generations), and each write appends an assertion naming rule_id.
Step 12 — Query the knowledge layer directly
The Knowledge endpoint is the search layer the agent uses. Pass memory_store_ids and document_paths to see which chunks would be injected for a question.
- CLI
- SDK
- curl
soat search-knowledge \
--project-id "$PROJECT_ID" \
--query "P1 outage response and how to reach Alice" \
--memory-store-ids '["'"$MEMORY_STORE_ID"'"]' \
--document-paths '["/alice/"]' \
| jq '.results[] | {score, similarity_score, source_type, content}'
Expected output (two 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.0328, "similarity_score": 0.50, "source_type": "memory", "content": "The Alice Corp fiscal year ends in March; she starts renewal discussions in January" }
Two scores, two contracts (Relevance scoring):
score: the reciprocal-rank-fusion value results are ordered by. The ordering is the contract, the number is not — do not persist it or show it as a percentage.similarity_score: raw cosine similarity, stable for comparing or logging, and the fieldmin_similarityfilters on.
They are different numbers: score encodes each result's position in the vector and lexical rankings, not how similar it is.
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_store_ids: [MEMORY_STORE_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_store_ids\":[\"$MEMORY_STORE_ID\"],\"document_paths\":[\"/alice/\"]}" \
| jq '.results[] | {score, similarity_score, source_type, content}'
Step 13 — Trace a fact back to the conversation it came from
A memory records whether there is a source to point at
(provenance). Every write so far has been a direct
generation with no conversation behind it, so all of them read manual with a null
source_id. Write one that names a conversation and the
pair becomes traceable.
- CLI
- SDK
- curl
CONVERSATION_ID=$(soat create-conversation \
--project_id "$PROJECT_ID" --name alice-renewal-call | jq -r '.id')
soat create-memory \
--memory-store-id "$MEMORY_STORE_ID" \
--content "Alice's account manager is Priya" \
--source_type conversation \
--source_id "$CONVERSATION_ID" \
| jq '{content, source_type, source_id}'
{
"content": "Alice's account manager is Priya",
"source_type": "conversation",
"source_id": "conv_0dR2mJk8xQ1vTbLp"
}
Read the whole store to see the two shapes side by side, then follow the id back:
soat list-memories --memory-store-id "$MEMORY_STORE_ID" \
| jq '[.data[] | {source_type, source_id}]'
SRC_ID=$(soat list-memories --memory-store-id "$MEMORY_STORE_ID" \
| jq -r '[.data[] | select(.source_type == "conversation")][0].source_id')
soat get-conversation --conversation-id "$SRC_ID" | jq '{id, name}'
A memory rule fills the same pair on its own: a turn
inside a conversation writes conversation plus that conversation's id, and a bare generation
writes manual, because there is nothing to name.
const { data: conversation } = await adminSoat.conversations.createConversation({
body: { project_id: PROJECT_ID, name: 'alice-renewal-call' },
});
const { data: sourced } = await Memories.createMemory({
client: authClient,
body: {
memory_store_id: MEMORY_STORE_ID,
content: "Alice's account manager is Priya",
source_type: 'conversation',
source_id: conversation.id,
},
});
console.log(sourced.source_type, sourced.source_id);
const { data: page } = await Memories.listMemories({
client: authClient,
query: { memory_store_id: MEMORY_STORE_ID },
});
page.data.forEach((e) => console.log(e.source_type, e.source_id));
const traced = page.data.find((e) => e.source_type === 'conversation');
if (traced?.source_id) {
const { data: source } = await adminSoat.conversations.getConversation({
path: { conversation_id: traced.source_id },
});
console.log(source.id, source.name);
}
CONVERSATION_ID=$(curl -s -X POST "$SOAT_URL/api/v1/conversations" \
-H "Authorization: Bearer $ADMIN_TOKEN" \
-H "Content-Type: application/json" \
-d "{\"project_id\":\"$PROJECT_ID\",\"name\":\"alice-renewal-call\"}" \
| jq -r '.id')
curl -s -X POST "$SOAT_URL/api/v1/memories" \
-H "Authorization: Bearer $ADMIN_TOKEN" \
-H "Content-Type: application/json" \
-d "{\"memory_store_id\":\"$MEMORY_STORE_ID\",\"content\":\"Alice's account manager is Priya\",\"source_type\":\"conversation\",\"source_id\":\"$CONVERSATION_ID\"}" \
| jq '{content, source_type, source_id}'
curl -s "$SOAT_URL/api/v1/memories?memory_store_id=$MEMORY_STORE_ID" \
-H "Authorization: Bearer $ADMIN_TOKEN" \
| jq '[.data[] | {source_type, source_id}]'
SRC_ID=$(curl -s "$SOAT_URL/api/v1/memories?memory_store_id=$MEMORY_STORE_ID" \
-H "Authorization: Bearer $ADMIN_TOKEN" \
| jq -r '[.data[] | select(.source_type == "conversation")][0].source_id')
curl -s "$SOAT_URL/api/v1/conversations/$SRC_ID" \
-H "Authorization: Bearer $ADMIN_TOKEN" | jq '{id, name}'
Provenance is set at creation and never rewritten. source_id is a loose pointer: deleting the conversation leaves the id in place, because the fact was still learned there. A fact that has changed is retired, not edited; --include-invalidated true on list-memories shows retired memories (Temporal invalidation), and list-memory-assertions on the replacement names the memory it retired.
What's next
- Tag-based filtering — one memory store per customer,
tagson the agent. - Dedup policy —
duplicate_thresholdandsupersede_thresholdset how close a fact must be to be skipped or to retire the one it restates, per store or per write (Memories). - Audit what an agent was told — pair provenance ids with the injected
<knowledge>block (Agents — Knowledge Config), whose source tags name the memory and document page behind each line.