How to Design a Typed Schema for Agent Memory
← Blog

Engineering·

How to Design a Typed Schema for Agent Memory

By Mac Anderson

  • Ontology
  • Agent Memory
  • Schema Design
  • Knowledge Graph
  • AI Agents
  • How-To

Most agent teams arrive at schema design the same way: RAG works for the first few demos, a structural query breaks, and someone asks "how do we actually model this?" The question is both easier and harder than it looks. Easier because the same few primitives turn up in every workable schema. Harder because the early decisions you make — what counts as an entity, how relationships are typed, where provenance lives — either carry you to production or become migrations you ship for the next year.

This guide walks the decisions concretely, with a worked example. The goal is not to hand you a universal schema — there isn't one — but to give you a sequence you can follow for your domain, and to show you the anti-patterns that silently bite once the corpus grows.

If you haven't read What Is an Ontology for AI Agents? yet, start there for the working definition. This piece assumes you've decided you need a typed graph and are now designing it.

Step 1: identify the nouns your agent reasons about

Start with the domain, not the data. Write down the five to fifteen nouns that your agent actually needs to reason about. For a sales-operations agent, that might be Person, Organization, Deal, Product, Meeting, Document. For a developer-productivity agent: Repository, Pull Request, Service, Incident, Deployment, Engineer. For a customer-support agent: Ticket, Customer, Product, Release, Known Issue.

These become your entity types. The test for "is this really an entity type?" is whether the agent will be asked to traverse across multiple instances of it — "which deals are at risk?", "which services own this dependency?" — rather than just describe one. If the agent only ever describes a single instance of a thing, it doesn't need its own type; it can be a property on a parent entity.

Keep the list small. Eight to twelve types is a healthy range for most agent domains. Forty types is a red flag — you've either split things that should be one type (SeniorEngineer and StaffEngineer are both Engineer with a seniority property), or you're modeling a full domain ontology rather than the narrow slice an agent needs.

Step 2: identify the verbs — typed relationships

For every pair of entity types where the agent might be asked "how are these related?", write down the verb. This gives you your relationship types.

For the sales-ops agent:

  • Person —works_at→ Organization
  • Person —owns→ Deal
  • Deal —for→ Organization
  • Meeting —attended_by→ Person
  • Deal —mentioned_in→ Meeting
  • Person —reports_to→ Person (self-edge)

Rule of thumb: directional verbs. works_at is different from employs. Use the form that matches the query the agent will generate. If the query is "who works at Acme?", the edge should be Person —works_at→ Organization so the traversal reads naturally.

Same quality bar as entity types: keep the vocabulary tight. A dozen relationship types for the core domain is plenty. Fifty is again a red flag — most "new" relationship types are usually variants of existing ones that could be distinguished by a property on the edge.

Step 3: pick the structural vs. property split

Here's the first decision with real cost attached: what belongs as a graph label, and what belongs as a property on the node?

Use a structural label for categories the query planner needs to filter or traverse on at scale. Three labels are enough for most agent ontologies:

  • Entity — anything with identity (people, companies, documents, services)
  • Observation — a fact the agent has extracted (with embedding, timestamp, confidence)
  • Source — where a fact came from (an email, a document, a tool call)

Use a property for the fine-grained type. entity_type: "person", entity_type: "organization". This is the decision that most agent teams get wrong — they lock in Person, Organization, Document as labels, then find themselves migrating the graph every time the extraction pipeline discovers a new type.

The cost of getting this wrong doesn't appear until month four. The cost of getting it right is almost zero: one composite index on (workspace_id, entity_type) and queries that filter by type perform identically to queries that traverse by label.

Step 4: schema-qualify your properties

Every node gets the same six properties, regardless of type:

id            — UUID, workspace-unique (global uniqueness is not required)
workspace_id  — the tenant / workspace this node belongs to
entity_type   — controlled vocabulary string
name          — primary display name (human-readable)
aliases       — list of alternate names, populated by entity resolution
created_at    — timestamp the agent first learned about this entity

Plus soft-delete markers (is_deleted, deleted_at) and audit fields (updated_at, confidence if extractions are noisy).

Fine-grained domain properties ride on top — a Person has email and title; a Service has language and repo_url — but the six above are the skeleton every node shares.

Step 5: design the observation node

This is the part most agent teams underthink. An Observation is the agent's memory of a single fact. "Sarah Chen is VP of Engineering at Acme." "The staging database runs PostgreSQL 15." "The customer prefers tables over prose."

id               — UUID
workspace_id     — tenant scope
content          — the fact in natural language
embedding        — vector for semantic retrieval
observed_at      — when the agent learned this
valid_from       — when the fact became true (often ≠ observed_at)
valid_to         — when the fact stopped being true, or null if still true
confidence       — float between 0 and 1
observation_type — "fact" | "episode" | "preference"
source_id        — pointer to the Source node

Two decisions here are easy to miss but important:

observed_at vs. valid_from. The agent can learn in April that Sarah became VP in January. observed_at = April, valid_from = January. Without the distinction, time-scoped queries ("who was VP in March?") give wrong answers.

observation_type. Facts are timeless claims ("Sarah is VP"); episodes are things that happened ("yesterday's standup covered the Q3 roadmap"); preferences are user-specific stable signals ("this user prefers concise bullet points"). Different observation types take different retrieval strategies — facts are ranked highly for factual queries, episodes for temporal ones, preferences for personalization. Lumping them together costs retrieval precision.

For the canonical schema Oxagen uses in production, see Knowledge Graphs for Agent Memory: Design Patterns.

Step 6: make provenance a first-class relationship

Every observation points to a Source. Every relationship edge carries a source_id property. This is the most important schema decision in the whole document.

Observation —recorded_in→ Source
(Entity)—[r:RELATED_TO {source_id: ...}]→(Entity)

Sources carry:

id               — UUID
workspace_id     — tenant scope
source_type      — "email" | "gmail" | "document" | "meeting" | "tool_call" | ...
source_ref       — the external identifier (email message ID, doc ID, etc.)
ingested_at      — when the pipeline processed this source
title            — human-readable label

The payoff: "why does the agent believe Sarah is VP of Engineering?" resolves to a traversal — Observation → Source → email_id — in one query. The alternative is scrolling through extraction logs.

For the full case on provenance and five other mistakes to avoid: 7 Mistakes Developers Make Building Ontologies for AI Agents.

Step 7: choose the shape of your relationship edges

Two viable patterns:

Option A: one edge type, relationship type as property

(a:Entity)-[r:RELATED_TO {
  relationship_type: "works_at",
  workspace_id: ...,
  valid_from: ...,
  valid_to: ...,
  confidence: ...,
  source_id: ...
}]->(b:Entity)

Option B: named edge types

(a:Entity)-[:works_at {valid_from: ..., source_id: ...}]->(b:Entity)
(a:Entity)-[:owns    {valid_from: ..., source_id: ...}]->(b:Deal)

Option A wins for agent ontologies. Named edge types feel cleaner, but the extraction pipeline discovers new relationship types faster than you want to alter the schema for. One edge type with a typed property lets the vocabulary evolve without any schema migration — the controlled vocabulary lives in a workspace config table, and the extraction pipeline validates against it at write time.

You lose a tiny bit of ergonomic niceness in Cypher (-[r:RELATED_TO]- instead of -[:works_at]-), and you gain a schema that can absorb a quarter's worth of extraction-pipeline evolution without a Monday-morning migration.

Step 8: workspace scoping — put it everywhere

Every node has workspace_id. Every edge has workspace_id. Every query filters on it. Every composite index includes it. Your query-builder layer refuses to compose a query without it.

This is the single highest-leverage schema invariant for multi-tenant agents — and one of the easiest to forget at design time because the prototype looks fine without it. Forgetting workspace scoping once is a cross-tenant incident. Forgetting it at the design layer makes the retrofit painful after there's real data.

Add it at the database layer too: row-level security in Postgres, label-scoped queries in Neo4j with a property check. Don't trust application-layer guards alone.

A worked example: sales-ops agent schema

Putting the pieces together, here's what the schema looks like for a sales-operations agent — the canonical "I want to ask natural-language questions about my CRM" use case.

Entity types (controlled vocabulary)

person
organization
deal
product
meeting
document

Relationship types

works_at         (Person → Organization)
owns             (Person → Deal)
for              (Deal → Organization)
includes_product (Deal → Product)
attended_by      (Meeting → Person)
discussed        (Meeting → Deal)
mentions         (Document → Entity)

Observation types

fact        — timeless claims
episode     — things that happened
preference  — user-specific signals

Source types

gmail
gcal
docs
hubspot
salesforce
user_statement

Example node: person

id           : 7ac3b1e2-...
workspace_id : 5f2c-...
entity_type  : "person"
name         : "Sarah Chen"
aliases      : ["Sarah", "S. Chen", "sarah@acme.com"]
email        : "sarah@acme.com"
title        : "VP of Engineering"
confidence   : 0.95
created_at   : 2026-03-15T10:30:00Z

Example observation

id              : f2e1a0b9-...
workspace_id    : 5f2c-...
content         : "Sarah Chen is VP of Engineering at Acme Corp"
embedding       : [0.02, 0.78, ..., -0.14]
observed_at     : 2026-03-15T10:30:00Z
valid_from      : 2026-01-01T00:00:00Z
valid_to        : null
confidence      : 0.92
observation_type: "fact"
source_id       : 8c9d-... (→ gmail message)

Example relationship

(:Entity {name: "Sarah Chen"})-[r:RELATED_TO {
  relationship_type: "works_at",
  workspace_id: 5f2c-...,
  valid_from: 2026-01-01T00:00:00Z,
  valid_to: null,
  confidence: 0.90,
  source_id: 8c9d-...
}]->(:Entity {name: "Acme Corp"})

All six node properties present, workspace-scoped, temporal, with provenance. It's not fancy. It's the schema that ships.

Anti-patterns to avoid

Four patterns that look reasonable on day one and turn into migrations on day 120:

Locking fine-grained types into graph labels. (:Person), (:Organization), (:Deal) feel natural. Agent extraction pipelines classify the same person as Customer, Lead, Contact, or Champion depending on which tool produced the observation. Every reinterpretation becomes a label change, which becomes a migration. Keep fine-grained types as properties.

Named relationship types instead of a typed edge property. Same reason. The extraction pipeline keeps finding new relationship types faster than you want to alter the schema.

Omitting valid_from/valid_to on edges. Temporal correctness is the second most common reason agents give wrong answers. Retrofit is painful — the data has to be backfilled by re-processing sources, which most teams skip and then ship with a gap.

Provenance as a string field on the node. Looks fine, works for the first month, then someone asks "which of these 200 observations came from the email thread that's been flagged?" and the string field is un-indexable. Provenance is a relationship, not a column.

How to evolve the schema in production

Three rules that keep the schema alive:

  1. Additive changes only by default. New properties on existing node types are nullable. Old queries that don't reference them keep working. Subtractive changes (removing a property) are rare, opt-in migrations.

  2. Types come from a controlled vocabulary. entity_type and relationship_type values are validated against a workspace-scoped config at write time. The extraction pipeline maps raw text to the vocabulary; if it can't, it falls back to a generic type (entity_type: "unknown") and flags the observation for review. This prevents type explosion — the slowest-moving but most eventually-fatal schema failure mode.

  3. Version observations when facts change. When "Sarah is VP of Engineering" becomes "Sarah is VP of Product," don't update the observation. Create a new observation with the current observed_at and set valid_to on the old one. The agent can answer both "what is true now?" and "what was true last quarter?" with the same traversal.

Starting points

If you're designing a schema for your domain from scratch, the sequence is:

  1. Write down eight to twelve entity types your agent must reason about
  2. Write down ten to twenty relationship types (directional verbs)
  3. Decide which properties every node needs (id, workspace_id, entity_type, name, aliases, created_at — plus domain-specific)
  4. Decide the observation node shape (content, embedding, temporal fields, source_id)
  5. Commit to a single RELATED_TO edge type with a typed property
  6. Put workspace_id on everything
  7. Make provenance a relationship
  8. Write three queries the agent will actually run and walk them through the schema to confirm the shape holds up

That eighth step is the one most teams skip and shouldn't. The schema that supports the queries you haven't written yet is more important than the schema that models the domain aesthetically.

Oxagen ships this schema pattern — typed entities, observations with embeddings and temporal fields, relationships with provenance, workspace-scoped from the root — as a Neo4j-backed ontology you plug into over MCP. Skip the schema-design debate and spend the quarter on the agent itself. Read the docs to get API access, or book a demo to see the schema running in production.

FAQ

How many entity types should an agent ontology have?

Eight to twelve for the core domain is a healthy range. Forty-plus is a red flag — you've either split things that should be one type (SeniorEngineer and StaffEngineer are both Engineer with a seniority property), or you're modeling a full domain ontology rather than the narrow slice an agent needs.

Should entity types be Neo4j labels or properties?

Properties, on a single Entity structural label. Agent extraction pipelines reclassify the same thing often — a person might be Customer, Lead, Contact, or Champion across different sources. Using labels means every reinterpretation is a schema change. Properties on a controlled vocabulary let the type evolve freely.

Do I need separate observed_at and valid_from timestamps?

Yes. observed_at is when the agent learned the fact; valid_from is when the fact became true in the world. The agent can learn in April that someone became VP in January — so observed_at = April, valid_from = January. Without the distinction, time-scoped queries like "who was VP in March?" return the wrong answer.

Is workspace scoping really needed if I only have one user now?

Yes, put it in from day one. Retrofitting workspace_id after accumulating data is painful — it requires re-ingesting or running backfills across every node and edge — and forgetting workspace scoping once after the retrofit causes a cross-tenant incident. Adding it at design time costs almost nothing.

What's the one decision that matters most in schema design?

First-class provenance. Every observation points to a Source node; every relationship edge carries a source_id. Debugging, auditability, and explainability all depend on being able to traverse from a claim back to the document or event that produced it. Treating provenance as a string field on the node instead of a relationship looks fine for a month and then becomes un-indexable.

Should I use named relationship types or a single RELATED_TO edge with a property?

Single edge type with a relationship_type property. Extraction pipelines discover new relationships faster than you want to alter the schema. A typed property lets the vocabulary evolve without migrations — you just update the workspace-scoped controlled vocabulary and keep writing.

Further reading