Skip to main content

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.
export SOAT_BASE_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.

ADMIN_TOKEN=$(soat login-user --username admin --password Admin1234! | jq -r '.token')
export SOAT_TOKEN=$ADMIN_TOKEN

Step 2 — Create a project

Every resource in SOAT lives inside a project. Create one to hold the memory and agent.

PROJECT_ID=$(soat create-project --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.

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"

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.

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"

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.

soat create-memory-entry \
--memory-id "$MEMORY_ID" \
--content "Alice prefers email over phone calls for all support communication"
# → { "action": "created", ... }

5b — Near-duplicate (action: skipped)

Almost identical to 5a, so the write is ignored.

soat create-memory-entry \
--memory-id "$MEMORY_ID" \
--content "Alice prefers email over phone calls"
# → { "action": "skipped", ... }

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.

soat create-memory-entry \
--memory-id "$MEMORY_ID" \
--content "Alice prefers email, especially for billing inquiries; she checks it twice a day"
# → { "action": "created", ... }

5d — Second distinct fact (action: created)

An unrelated fact is stored as a new entry.

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", ... }

Step 6 — List entries to verify

After the four writes, the memory holds exactly three entries — only the near-duplicate from 5b was discarded.

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"
# ]

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.

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"

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.

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"

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).

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.


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:

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}'

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:

soat list-memory-entries --memory-id "$MEMORY_ID" \
| 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:

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}'

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:

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}]'

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.

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:

  • score is the relevance ranking. Results are ordered by it and min_score filters on it. It is implementation-defined: the ordering is the contract, the number is not. Tune min_score against it for this deployment, and re-tune after an upgrade rather than treating a value as portable.
  • similarity_score is 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.


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:

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.

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_tags on the agent scope retrieval per customer.
  • Adjust the dedup threshold — tune duplicate_threshold to 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.