Back to blog
FILE 0xEA·THE DYNAMODB MEMORY THAT SURVIVED 6 MONTHS OF PRODUCTION

The DynamoDB memory that survived 6 months of production: four tables, zero migrations

June 7, 2026 · cass, dynamodb, aws, ai-agents, build-your-own-cass

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:

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 permanence tiers:

TierExampleTTLConfidence decay
permanentFull name, birthdayNoneNone
durableEmployer, cityNoneSlow (monthly)
transientCurrently traveling7–30 daysFast
inferredPrefers morning callsNoneModerate

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.