The DynamoDB memory that survived 6 months of production: four tables, zero migrations
Six months ago I gave Cass (my personal AI agent) persistent memory backed by DynamoDB. In that time I’ve added new fact types, changed the conversation format, added tool logging, and introduced preferences — and not once have I had to migrate existing data.
This is the schema that made that possible.
Why DynamoDB for agent memory
The constraint that makes AI agent memory different from regular app storage: you don’t know what you’ll need to remember.
A user record is predictable. An agent’s memory isn’t. Today it stores facts about the user’s job and home. Tomorrow it might need to remember a phone number, a preference for terse responses, or that a particular tool caused problems last Tuesday. The schema has to absorb new categories of information without an ALTER TABLE ADD COLUMN.
DynamoDB’s schema-free items solve this naturally. You define the key structure; everything else in each item is optional and can change over time. New fact types don’t require schema changes — they’re just items with a new type attribute.
The four tables
1. Conversations
Stores the raw message history.
Table: cass_conversations
PK: user_id (S)
SK: message_id (S) — ISO timestamp + UUID suffix
Attributes:
role: "user" | "assistant"
content: (string)
timestamp: (string ISO 8601)
session_id: (string) # group messages into sessions
ttl: (number) # Unix epoch, 90 days from insert
GSI: SessionIndex
PK: session_id
SK: timestamp
Key decisions:
- TTL of 90 days. Conversations decay; long-term context should be extracted and moved to the facts table, not kept as raw messages forever.
- SK is
timestamp + uuidso sort order matches insertion order. Pure timestamps collide for rapid messages.
Loading history for a new call:
def load_recent_history(user_id: str, max_chars: int = 4000) -> list[dict]:
resp = table.query(
KeyConditionExpression=Key("user_id").eq(user_id),
ScanIndexForward=False,
Limit=100
)
messages = resp.get("Items", [])
total = 0
result = []
for msg in reversed(messages):
content = msg.get("content", "")
total += len(content)
if total > max_chars * 4:
break
result.append({"role": msg["role"], "content": content})
return result
You’re building a sliding window of recent context, not loading everything forever.
2. Facts
Long-lived knowledge about the user that shouldn’t expire.
Table: cass_facts
PK: user_id (S)
SK: fact_id (S) — category#subcategory#key
e.g., "personal#location#city"
Attributes:
value: (string or JSON string)
confidence: (number) — 0.0–1.0
source: "user_stated" | "inferred" | "confirmed"
permanence: "permanent" | "durable" | "transient" | "inferred"
created_at: (string ISO 8601)
updated_at: (string ISO 8601)
ttl: (number) — only set for permanence=transient
Key decisions:
- The SK encodes category hierarchy as a path. This lets you query all personal facts (
SK begins_with "personal#"), or all location facts (SK begins_with "personal#location#"). confidencedecays over time for inferred facts. A fact inferred from a throwaway comment (confidence: 0.3) should be treated differently than one the user stated directly (confidence: 1.0).- Transient facts get a TTL. Permanent facts don’t.
The permanence tiers:
| Tier | Example | TTL | Confidence decay |
|---|---|---|---|
| permanent | Full name, birthday | None | None |
| durable | Employer, city | None | Slow (monthly) |
| transient | Currently traveling | 7–30 days | Fast |
| inferred | Prefers morning calls | None | Moderate |
The mistake I made initially: I stored inferred facts with the same confidence as stated ones. Cass started treating weak inferences as ground truth. Adding the confidence field and decay logic fixed this.
3. Preferences
Settings that change the agent’s behavior.
Table: cass_preferences
PK: user_id (S)
SK: preference_key (S)
e.g., "response.verbosity", "notifications.digest_time"
Attributes:
value: (string)
set_by: "user" | "default"
updated_at: (string ISO 8601)
Why separate from facts: Facts describe the world. Preferences configure the agent. Different data category, different query pattern. You load all preferences at session start (query PK=user_id); you don’t do that for facts — you look up specific categories.
4. Tool log
A record of every tool call the agent made.
Table: cass_tool_log
PK: user_id (S)
SK: timestamp (S) — ISO 8601 + UUID suffix
Attributes:
tool_name: (string)
inputs: (string JSON)
output_summary: (string — first 500 chars)
status: "ok" | "error" | "timeout"
duration_ms: (number)
session_id: (string)
ttl: (number) — 30 days
When Cass makes the same mistake twice, the tool log lets you trace it. Pull all tool calls for a session and see exactly what happened. The 30-day TTL is enough for debugging; this isn’t an audit log.
Zero migrations
In the six months since deploying this, I’ve added new fact categories, new preference keys, a session_id attribute to conversations, and an output_summary field to the tool log. None required touching existing items. DynamoDB items just don’t have those attributes yet, and reads default missing fields to None in Python.
The only thing that required a data change was renaming a fact category. I wrote a one-time script to update the SKs. That’s not a migration in the relational sense — it’s a targeted backfill on a few dozen items.
The context builder
def build_context(user_id: str) -> str:
facts = load_facts(user_id)
prefs = load_preferences(user_id)
context_lines = []
if facts:
context_lines.append("## What I know about you")
for fact in facts:
context_lines.append(f"- {fact['sk'].split('#')[-1]}: {fact['value']}")
if prefs:
context_lines.append("## Your preferences")
for pref in prefs:
context_lines.append(f"- {pref['sk']}: {pref['value']}")
return "\n".join(context_lines)
The agent gets this context block at the top of every session. It doesn’t have to look up facts mid-conversation; they’re already there.
Module 2 of Build Your Own Cass has the complete implementation — DynamoDB table definitions, all four schemas, the context builder, and the tool that lets the agent update its own memory mid-conversation.