Every session starts cold. Users re-teach the same preferences. Here's how to give your agent real memory — without creating a privacy disaster in the process.
Updated 4 October 2026 with technical corrections.
A researcher opened our AI agent on a Tuesday morning and typed a question about gene dependency scores. The agent came back with RNAi-based scores. The researcher sighed, typed the same correction they had typed the previous Thursday: "Use Chronos scores, not RNAi. Chronos is copy-number corrected."
The agent apologized and switched to Chronos. It got the question right. The session ended. On Wednesday morning, the same researcher asked a similar question. The agent came back with RNAi scores again.
This is not an edge case. This is what stateless agents do. Every session begins with a blank slate. Users who interact with an agent daily don't experience it as helpful — they experience it as a tool with amnesia that they have to re-train each morning at the cost of their own time and patience.
We fixed it by implementing cross-session memory using Amazon Bedrock AgentCore Memory. This post covers how we built it, what traps we hit, and — just as importantly — what we deliberately chose not to store.
Most production AI agents are stateless by design. The LLM itself has no memory between calls. Session history is injected into each request as a messages array, and that history lives in a database that expires after N days. When the TTL fires, the context is gone.
This architecture has real advantages: it's simple, it's cheap, and there are no cross-request data dependencies to manage. But it creates a specific kind of user experience that erodes trust over time. Users who use the agent regularly don't get better results over time. The agent never learns that this particular scientist prefers a specific scoring method, always pre-resolves cell lines before joining DepMap data, or has already confirmed that GENE-A is essential in the cell lines they work with.
The real cost of amnesia: It's not just friction. When a researcher has to correct the same methodological preference for the third time, they start to distrust the agent's answers more broadly. "If it gets this wrong every morning, what else is it quietly getting wrong?" Statelessness doesn't just waste time — it undermines the credibility of every response.
The obvious fix — just give it a longer session history — doesn't work at scale. A session history that extends weeks backwards is expensive in tokens, and more importantly, it's full of noise. Old tool results, exploratory queries that went nowhere, hallucinations that were corrected. You don't want any of that injected into every new session.
What you want is the distilled signal: preferences, corrections, confirmed findings. Not a transcript — a profile.
Amazon Bedrock AgentCore Memory is a managed service that runs extraction strategies asynchronously in the background. The architecture has two components: raw events and extracted records.
You write events to the memory store after each query. An event is a conversational exchange: the user's query and the agent's response. The service runs your configured strategies against these events asynchronously — AWS documents this as "a minute or more," though in practice USER_PREFERENCE records often appear in around 25 seconds. EPISODIC reflections may take longer since the service waits to detect a completed episode before generating them. The strategies extract structured records from the raw text: preferences, findings, factual corrections. Those extracted records are what you retrieve at the start of each new session.
The key insight: You're not storing conversation history. You're storing extracted knowledge. The extraction step is what separates "agent memory" from "a very long session log."
The payload format has a few non-obvious requirements that aren't clearly documented. The field name is conversational — not conversationalMessage, not message. And the content inside it must be a dict, not a list.
# Correct payload format
payload = [{
"conversational": {
"role": "USER",
"content": {"text": "query text here"}
}
}]
# create_event also requires an explicit event-timestamp
import datetime
event_ts = datetime.datetime.now(datetime.timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
client.create_event(
memoryId=MEMORY_ID,
actorId=actor_id, # keyed hash of the email, never the raw address
sessionId=session_id,
eventTimestamp=event_ts,
payload=payload
)
The eventTimestamp parameter (--event-timestamp in the CLI) is required — omitting it causes an error. And the template variables for namespace configuration are {actorId}, {sessionId}, and {memoryStrategyId}. Not {userId}.
AgentCore Memory supports five strategy types, but three are most useful for production agent deployments:
| Strategy | What it extracts | Extracted structure | Best for |
|---|---|---|---|
| USER_PREFERENCE | Workflow preferences expressed by the user | {"preference": "...", "context": "...", "categories": [...]} |
Methodological corrections: scoring choices, tool sequencing, output format preferences |
| EPISODIC | Episodes from completed interactions, plus reflection records that distil recurring patterns across episodes into reusable insights | {"title": "...", "hints": "...", "use_cases": [...], "confidence": ...} |
Lessons about how this user's analyses tend to go — which approaches worked and which failed. Note: extraction waits until AWS detects a completed episode; extraction latency is longer than USER_PREFERENCE. |
| SEMANTIC | Factual domain knowledge and correction rules | {"fact": "...", "summary": "..."} |
Factual corrections and confirmed findings: data model rules, identifier format requirements |
There is a critical constraint: you can only have one strategy of each type per memory resource. If you try to create two USER_PREFERENCE strategies, the API returns an error. This seems obvious in retrospect, but it's not prominently documented. The implication: you cannot have separate "preferences" and "corrections" strategies both typed as USER_PREFERENCE. You need to use SEMANTIC for corrections and USER_PREFERENCE for workflow preferences.
Each strategy writes to a namespace path derived from template variables. Use namespaceTemplates in the strategy configuration — not namespaces. The path structure should scope to {actorId}:
# Good: one namespaceTemplate per strategy (each strategy is a separate object)
# USER_PREFERENCE strategy:
namespaceTemplates: ["/app/preferences/{actorId}/"]
# SEMANTIC strategy (corrections):
namespaceTemplates: ["/app/corrections/{actorId}/"]
# EPISODIC strategy (episodes, per session):
namespaceTemplates: ["/app/findings/{actorId}/{sessionId}/"]
# ...its reflections get their own namespace — keep it actor-scoped;
# a strategy-level reflection namespace mixes users
reflectionConfiguration: {namespaceTemplates: ["/app/findings/{actorId}/"]}
# Bad: multiple templates in one strategy — API only accepts 1 template per strategy
# Also bad: global namespace — returns ALL users' data on retrieve
namespaceTemplates: ["/app/"]
The extracted content comes back as a JSON string inside content.text, not plain text. Your retrieval code needs to json.loads() the content (falling back to the raw text when it isn't JSON) to access the structured fields. If you try to surface it directly as text, you'll inject raw JSON into the agent's context which it will attempt to parse itself, with unpredictable results.
This one deserves its own section because the failure mode is permanent and silent.
When you retrieve memories, you pass a namespacePath prefix. AgentCore returns all records whose namespace starts with that prefix. If you pass /app/, you get records from every user who has ever interacted with the system. Researcher A's preferences show up in Researcher B's session context. This is not a hypothetical — we verified it against the live service before shipping.
The permanent leak risk: Extracted long-term records have no automatic TTL. The eventExpiryDuration setting governs only the raw event records, not what the strategies extract. A preference or correction record written with the wrong namespace scope will remain there indefinitely unless you explicitly delete it via DeleteMemoryRecord.
Never store the raw user email as the actorId. Instead, derive it with a keyed hash (HMAC):
import hashlib, hmac
ACTOR_ID_KEY = load_secret("actor-id-key").encode() # server-side secret, never shipped to clients
def _hash_actor(email: str) -> str:
# Fail close on empty/fallback identity
if not email or email.strip().lower() in ("", "anon", "anonymous"):
raise ValueError(
f"Cannot derive actorId from empty/fallback email: {email!r}"
)
return hmac.new(ACTOR_ID_KEY, email.strip().lower().encode(), hashlib.sha256).hexdigest()[:16]
The fail-close on empty identity is important. If your auth layer fails to inject a user email and your code falls back to an empty string or "anon", the hash of that string becomes a shared actorId for every unauthenticated request. Every preference and finding gets written to the same namespace. Every user gets every other user's context. And since extracted records have no TTL, this contamination persists until manually cleaned up.
Raising an exception on empty identity is the correct behavior. Write no memory rather than write to a shared namespace.
Writing events after every query is one half of the memory system. The other half is capturing explicit corrections from user feedback.
In our system, users can give thumbs-up or thumbs-down ratings on agent responses, and optionally leave a comment. We write negative feedback with substantive comments as a correction event. Every strategy sees every event — a [CORRECTION] prefix on the text nudges the SEMANTIC strategy, but it doesn't route the event — so when a correction must land in one namespace only, write the record directly with BatchCreateMemoryRecords instead. The gating looks like this:
# Only write corrections on explicit thumbs-down with a real comment
if rating == -1 and comment and len(comment.strip()) > 20:
background_tasks.add_task(
write_correction,
actor_id=actor_id,
session_id=session_id,
content=comment.strip()
)
# Only write preferences on thumbs-up with comment
elif rating == 1 and comment:
background_tasks.add_task(
write_preference,
actor_id=actor_id,
session_id=session_id,
content=comment.strip()
)
This sounds simple. Before enabling the write path, we queried the feedback table to understand the baseline signal. What we found stopped us.
The AND condition in our write path — rating = -1 AND comment IS NOT NULL — had never been true once. Hundreds of rows in the feedback table. A couple with rating = -1. A few with a comment. Zero with both. The write path was unreachable, not broken.
The problem was in the feedback UI. When a user clicked 👎, the feedback bar immediately collapsed to a "Thanks for your feedback!" message. The comment box — which was inside the now-collapsed bar — was gone. Users couldn't add a comment after clicking thumbs-down. They had to click the comment button first, then click thumbs-down, in a non-obvious sequence that nobody discovered.
The fix was three lines of JavaScript: on thumbs-down, instead of collapsing the bar, auto-open the comment input and keep the bar visible. Only collapse after the comment is submitted. The entire corrections pipeline was gated on this UI state flag.
Lesson: Test the full feedback pipeline end-to-end against your production data before concluding it works. Checking that the write code is correct is not the same as checking that the write path is reachable. Query your actual table for co-occurring rating = -1 AND comment IS NOT NULL rows. If you find zero, the problem is probably in your UI.
The temptation when building agent memory is to store everything. Tool results, intermediate calculations, data the agent retrieved, every conversation turn. Don't.
Three rules we apply:
What belongs in memory: workflow corrections ("always use Chronos, not RNAi"), confirmed scientific findings ("GENE-A is a dependency in this lab's cell-line panel"), identifier format preferences ("use ProteinIds not GeneNames for bare identifiers"). What does not: specific numbers, intermediate results, exploratory queries that went nowhere.
The retrieval runs on every request, before the agent sees the user's message, using that message as the search query. You inject the retrieved memories as synthetic conversational turns early in the messages array:
# Retrieve BEFORE extending the messages array with session history
# Returns a formatted string (not a list) — use directly in the text field
_memory_context: str | None = get_session_memory_context(actor_id=actor_id, query=user_query)
# Prepend as synthetic turns — content must be a list (Bedrock Converse format)
if _memory_context:
prepend = [
{
"role": "user",
"content": [{"text": f"Historical notes from past sessions (unverified — re-check anything factual with a tool call):\n{_memory_context}"}]
},
{
"role": "assistant",
"content": [{"text": "Historical notes loaded."}]
}
]
messages = prepend + _load_messages_from_history(history)
else:
messages = _load_messages_from_history(history)
# IMPORTANT: capture prior_message_count AFTER the prepend
prior_message_count = len(messages)
The ordering matters. If you capture prior_message_count before the prepend, the delta slice starts two messages early. The first request writes the synthetic turns into your session store, and every later request writes two stored turns back again — the history grows with duplicates on every request, and each one is reloaded and resent.
We verified the round-trip end-to-end using a synthetic event: write an event, wait, retrieve — the extracted USER_PREFERENCE record appeared correctly structured in the actor-scoped namespace in about 25 seconds.
The session-init context injection also changes how the agent handles mid-session corrections. Simply having the agent instructions say "if you see historical notes at the start of this session, treat them as this user's stated preferences, not as evidence about the data" makes the agent acknowledge and apply corrections more consistently within the current session.
The longer-term loop — corrections accumulating, being surfaced at the next session start, changing the agent's behaviour for that user — runs on the same path. The next layer is the cluster-level pattern: when three or more users make the same correction, that's a signal the agent's default behavior needs to change for everyone, not just those individuals. That's a skill update, not a memory write. The two mechanisms serve different scopes.
namespacePath prefix must be scoped to {actorId}; a global prefix returns all users' data and constitutes a privacy violation.actorId should be a keyed hash (HMAC) of the user's email, never the raw email. Fail close on empty identity — never hash an empty string.prior_message_count, or every request writes duplicate turns into your session history.Follow-up (September 2026): When Your Agent’s Memory Starts Lying: Self-Poisoning in Long-Term Agent Memory — explores how persistent memory can turn against the agent when hallucinations re-enter as trusted rules.
Disclaimer: The views and opinions expressed in this post are my own and do not represent those of my employer or any organisation I am associated with.