Schema Design Patterns for Agent Memory
Why schema matters
A schema is a contract between memory writes and reads. Without one, you end up with a schemaless blob that's expensive to query and impossible to evolve.
Bad: Store everything as { content: string } and figure out structure on read.
Good: Define upfront: fact vs. preference vs. event vs. entity, queryable fields, field cardinality.
Core types
Brain's memory model defines five semantic types. Use these as your schema foundation.
1. Fact
Persistent, low-cardinality assertions about the world.
type FactMemory = {
type: "fact";
content: string; // "User is based in San Francisco"
confidence: number; // 0-1, learned from context
source: "explicit" | "inferred" | "system";
ttl?: DateTime; // Facts can expire
};When to use: User location, expertise, preferences, constraints. Cardinality: 1–100 per user. Retrieval: Mostly keyword + metadata (location='SF').
Schema fields:
{
type: "fact",
category: "location" | "expertise" | "preference" | "constraint",
field: string, // "home_city", "technical_stack", "dietary_pref"
value: string | number | boolean,
confidence: 0-1,
source: string,
}2. Preference
Stateful user choices that shape agent behavior.
type PreferenceMemory = {
type: "preference";
dimension: "communication" | "style" | "pace" | "format";
value: string;
strength: "soft" | "hard"; // hard = non-negotiable
learned_from: string; // "user_stated" | "behavior"
};When to use: Communication style (async/sync), response length, formality, timezone. Cardinality: 5–20 per user. Retrieval: Exact match on dimension (communication_style='async').
3. Event
Time-ordered occurrences with causality.
type EventMemory = {
type: "event";
action: string; // "user_asked", "agent_decided", "task_completed"
timestamp: DateTime;
outcome: "success" | "failure" | "partial";
context: { [key: string]: any };
next_action?: string; // Causal link
};When to use: Conversation turns, task completions, errors, decisions. Cardinality: 100–10K per conversation. Retrieval: Temporal (last 10 events) + semantic (events about pricing).
4. Entity
Named things with attributes and relationships.
type EntityMemory = {
type: "entity";
entity_id: string; // "user_u1", "company_acme", "product_brain"
entity_type: string; // "user", "company", "product"
attributes: {
name: string;
category?: string;
description?: string;
[key: string]: any;
};
relationships?: {
[relation_type]: string[]; // "works_at": ["company_acme"]
};
};When to use: People, companies, products, concepts that appear repeatedly. Cardinality: 10–500 per scope. Retrieval: Entity lookup (company='Acme') + graph traversal (employees of Acme).
5. Relation
Explicit edges between entities.
type RelationMemory = {
type: "relation";
subject_id: string; // "user_u1"
predicate: string; // "works_at", "authored", "owns"
object_id: string; // "company_acme"
confidence: number;
context?: string;
};When to use: Explicit relationships (user works at company, message authored by user). Cardinality: 1–5 per entity pair. Retrieval: Graph queries (find all people who work at Acme).
Schema design checklist
1. Queryability
Every field you might filter by should be a first-class field, not buried in content.
// ❌ Wrong: buried in content
{
type: "fact",
content: "User is in San Francisco, timezone PST, works at Acme"
}
// ✓ Right: queryable fields
{
type: "fact",
category: "location",
field: "home_city",
value: "San Francisco",
metadata: {
timezone: "PST",
company: "Acme",
}
}2. Cardinality awareness
High-cardinality fields (thousands of unique values per user) are expensive to index and filter.
Low cardinality (good):
communication_preference: "async" | "sync"timezone: "PST" | "EST" | ... (< 50 values)expertise_level: "junior" | "mid" | "senior"
Medium cardinality (OK):
home_city: San Francisco, NYC, London, ... (< 1000 values)programming_language: Python, JS, Go, ... (< 100 values)
High cardinality (avoid indexing, use in metadata):
email_address: millions of unique valuesuser_comment: millions of unique valuesproduct_sku: millions of unique values
3. Temporal fields
Always include timestamp and optional ttl. Memory without temporal context is noisy.
{
type: "event",
content: "User asked about pricing",
timestamp: new Date("2026-05-12T14:30:00Z"),
ttl: new Date("2026-05-19T14:30:00Z"), // Expires in 1 week
}4. Versioning
Schema evolves. Plan for backward compatibility.
{
type: "fact",
schema_version: 2, // Increment when you add/remove fields
content: "...",
// v2 additions:
category: "location",
confidence: 0.95,
}Anti-patterns
| Anti-pattern | Why it fails | Fix |
|---|---|---|
Everything is content | Can't filter, all semantic search | Use typed fields + metadata |
| Over-normalized (100+ tables) | Too many joins, slow queries | Denormalize; use JSON metadata |
| No TTL | Memory grows unbounded | Set TTL on transient types |
| High-cardinality indexes | Index explosion, slow writes | Keep cardinality < 1000 per field |
| Mutable IDs | Relationship links break | Use immutable UUIDs; never change |
| No timestamp | Can't answer "when did this happen?" | Always include timestamp |
Example: Multi-turn conversation schema
type ConversationSchema = {
// Metadata (queryable)
agent_id: string;
user_id: string;
conversation_id: string;
session_start: DateTime;
// Events (time-ordered)
turns: Array<{
type: "event";
action: "user_message" | "agent_response" | "action_taken";
timestamp: DateTime;
content: string;
embedding: Vector;
metadata: {
turn_number: number;
intent?: string; // "question", "command", "clarification"
sentiment?: "positive" | "neutral" | "negative";
successful: boolean;
};
}>;
// Facts (extracted, persistent)
facts: Array<{
type: "fact";
field: "user_intent" | "user_preference" | "user_constraint";
value: string;
confidence: number;
learned_turn: number;
}>;
// Entities (referenced throughout)
entities: Array<{
type: "entity";
entity_id: string;
entity_type: "user" | "product" | "company";
attributes: { name: string; [key: string]: any };
}>;
};Queryability: Can filter by user_id, agent_id, intent, sentiment. Can retrieve by turn number (recency) or semantic search (what was the user's intent?).
Cardinality: Low (< 50 unique intents), medium (< 1000 users), high only in content (not indexed).
Schema evolution
Your schema will change. Plan for it.
Add a field
Old records have undefined for new fields. Backfill or use defaults.
// v1 → v2: add confidence field
type FactMemoryV1 = { type: "fact"; content: string };
type FactMemoryV2 = { type: "fact"; content: string; confidence: number };
// On read, migrate
function migrateV1toV2(fact: FactMemoryV1): FactMemoryV2 {
return { ...fact, confidence: 0.5 }; // Default for backfilled
}Remove a field
Mark deprecated, don't delete. Migrate readers to new field.
type FactMemoryV2 = {
type: "fact";
content: string; // Deprecated in v3, use `summary`
summary?: string;
};Rename a field
Create new field, migrate, deprecate old.
// v2: user_preference
// v3: communication_preference
type FactMemoryV3 = {
communication_preference?: string; // v3+
user_preference?: string; // deprecated, v2 compat
};Testing your schema
Before shipping, validate:
- Every queryable field is indexed
- Cardinality of indexed fields < 10K per user
- All records have
timestamp - TTL is set for transient types
- No field is both high-cardinality and indexed
- Backward compatibility path exists for next version
- You can answer: "How do I find X?" for every use case
Further reading
- Memory Architectures — System design
- Cheat Sheet — Quick reference
- Top Mistakes — What not to do