Docs
View as markdown

Model agent memory

Lay out an agent's long-term memory as a graph of entities, episodes and bi-temporal facts with their provenance, then recall it in one Cypher statement.

cypheragentsmcp

Before you start: Your first graph in the CLI

This guide shows you how to model an agent's long-term memory as a graph: the entities it knows about, the episodes it learned things in, and facts that carry two time ranges and a link to their evidence. The cells on this page run in order on one in-memory store A relate database: a directory, a single .rl file, or :memory:, opened with --database. Read more in the glossary, so by the end the page holds a working memory you can query.

Prerequisites

  • Cypher reads and writes with parameters, as in Your first graph.
  • Embeddings from your model for each fact and each question. The examples use 4-dimension vectors written by hand, so their results never change; a real model's vectors have hundreds of dimensions.

The model

Three node labels and four relationship types. Nothing is ever deleted: a correction closes the old fact and links it from the new one.

ElementHoldsRole
Entitykey, name, plus its domain labelsWhat memory is about. key is unique, so every write finds the same node
Episodeid, session, kind, at, textWhere and when the agent learned something: a message, or a tool call and its result
Factid, text, slot, validFrom, validTo, recordedAt, closedAt, embeddingOne statement about entities. slot names what it answers, and holds one current fact at a time
(:Episode)-[:MENTIONS]->(:Entity)The entities an episode names
(:Fact)-[:FROM]->(:Episode)Provenance: the evidence for the fact
(:Fact)-[:ABOUT]->(:Entity)What the fact describes
(:Fact)-[:SUPERSEDES]->(:Fact)A correction and the fact it replaced

A fact has two time axes. Valid time, validFrom to validTo, is when it held in the world. Record time, recordedAt to closedAt, is when the agent believed it. A late correction makes them disagree, and the graph keeps both answers.

Data model: entities, the episodes that mention them, and facts with their evidenceAn Episode MENTIONS the Entity nodes it names. A Fact is ABOUT one or more entities and comes FROM the Episode that is its evidence. A Fact carries valid time and record time, and a newer Fact SUPERSEDES the one it replaced in the same slot.Episodeidsession · kindat · textEntitykey · nameFacttextslot · embeddingvalidFrom · validTorecordedAtclosedAtMENTIONSABOUTFROMSUPERSEDES
Data model: entities, the episodes that mention them, and facts with their evidenceAn Episode MENTIONS the Entity nodes it names. A Fact is ABOUT one or more entities and comes FROM the Episode that is its evidence. A Fact carries valid time and record time, and a newer Fact SUPERSEDES the one it replaced in the same slot.Episodeidsession · kindat · textEntitykey · nameFacttextslot · embeddingvalidFrom · validTorecordedAtclosedAtMENTIONSABOUTFROMSUPERSEDES
Data model: entities, the episodes that mention them, and facts with their evidence

In words: An Episode MENTIONS the Entity nodes it names. A Fact is ABOUT one or more entities and comes FROM the Episode that is its evidence. A Fact carries valid time and record time, and a newer Fact SUPERSEDES the one it replaced in the same slot.

Once the cells on this page have run, the database can report its own model. CALL db.schema.visualization() returns one node per label, with its indexes and constraints, and one relationship per type and pair of labels:

The figure above is the model as written. CALL db.schema.visualization() reports the model the page's database holds once the cells have run: one node per label, one relationship per type and pair of labels, and each label's indexes and constraints. When the page was recorded, those cells wrote 11 nodes, 21 relationships, 2 indexes and 3 constraints.

Live: a button runs CALL db.schema.visualization() on the page's database in your browser once its cells have run, and draws the labels and relationship types it reports with the code that draws the figure, beside a table of each label's indexes and constraints.

Start from the graph you have

Memory attaches to the domain graph, in this guide the shared example graph: three people and two projects.

Cypher
CREATE (ada:Person {name: 'Ada', role: 'engineer'}),
       (grace:Person {name: 'Grace', role: 'engineer'}),
       (noor:Person {name: 'Noor', role: 'maintainer'}),
       (relate:Project {name: 'relate', language: 'Rust'}),
       (tck:Project {name: 'opencypher-tck', language: 'Gherkin'}),
       (ada)-[:WORKS_ON {since: 2024}]->(relate),
       (grace)-[:WORKS_ON {since: 2023}]->(relate),
       (grace)-[:WORKS_ON {since: 2022}]->(tck),
       (noor)-[:MAINTAINS]->(relate),
       (ada)-[:KNOWS]->(grace)
Recorded result
(no rows) · nodes created: 5, relationships created: 5, properties set: 13, labels added: 5

Every person and project becomes an entity, with a lowercase key that writes can match on:

Cypher
MATCH (n) WHERE n:Person OR n:Project
SET n:Entity, n.key = toLower(n.name)
RETURN n.key AS key, labels(n) AS labels
ORDER BY key
Recorded result
keylabels
"ada"["Person", "Entity"]
"grace"["Person", "Entity"]
"noor"["Person", "Entity"]
"opencypher-tck"["Project", "Entity"]
"relate"["Project", "Entity"]

5 rows · properties set: 5, labels added: 5

Create the schema

Uniqueness constraints keep one node per entity key, episode, and fact. A range index serves lookups by slot. The vector index serves recall by meaning; WITH [f.closedAt] declares the property a search may filter on inside the index, so recall can skip closed facts before it counts to k.

Cypher
CREATE CONSTRAINT entity_key IF NOT EXISTS FOR (e:Entity) REQUIRE e.key IS UNIQUE;
CREATE CONSTRAINT episode_id IF NOT EXISTS FOR (ep:Episode) REQUIRE ep.id IS UNIQUE;
CREATE CONSTRAINT fact_id IF NOT EXISTS FOR (f:Fact) REQUIRE f.id IS UNIQUE;
CREATE INDEX fact_slot IF NOT EXISTS FOR (f:Fact) ON (f.slot);
CREATE VECTOR INDEX fact_embedding IF NOT EXISTS
  FOR (f:Fact) ON (f.embedding) WITH [f.closedAt]
  OPTIONS {indexConfig: {`vector.dimensions`: 4, `vector.similarity_function`: 'cosine'}}
Recorded result
(no rows) · indexes added: 2, constraints added: 3

Remember a session

In session s1 the agent hears two messages and extracts one fact from each. The extraction is your model's job; this block holds its output.

The first statement writes the episodes and what they mention. The second writes each fact FROM its episode and ABOUT its entities. A fact's recordedAt is its episode's time. vector(f.embedding, 4, FLOAT32) stores a typed vector and fails the statement if an embedding has the wrong length.

Cypher
UNWIND $episodes AS e
CREATE (ep:Episode {id: e.id, session: e.session, kind: e.kind, at: datetime(e.at), text: e.text})
WITH ep, e
UNWIND e.mentions AS key
MATCH (n:Entity {key: key})
CREATE (ep)-[:MENTIONS]->(n);

UNWIND $facts AS f
MATCH (ep:Episode {id: f.episode})
CREATE (x:Fact {id: f.id, text: f.text, slot: f.slot,
                validFrom: datetime(f.validFrom), recordedAt: ep.at,
                embedding: vector(f.embedding, 4, FLOAT32)})-[:FROM]->(ep)
WITH x, f
UNWIND f.about AS key
MATCH (n:Entity {key: key})
CREATE (x)-[:ABOUT]->(n);

MATCH (x:Fact)-[:FROM]->(ep:Episode)
RETURN ep.id AS episode, x.text AS fact, x.slot AS slot
ORDER BY episode
Parameters
Recorded result
episodefactslot
"e1""Grace reviews each relate release.""relate/release-reviewer"
"e2""Noor triages new relate issues on Mondays.""relate/triage"

2 rows · nodes created: 4, relationships created: 10, properties set: 22, labels added: 4

Record a correction

On 20 March, in session s2, a tool call reads the repository's owners file and finds that Ada took over release review on 10 March. The agent was wrong for ten days, and now it knows.

One statement writes the episode and the new fact, then closes the current fact in the same slot: its validTo becomes the new fact's validFrom, its closedAt the time the agent learned it.

Cypher
WITH $episode AS e, $fact AS f
CREATE (ep:Episode {id: e.id, session: e.session, kind: e.kind, at: datetime(e.at), text: e.text})
CREATE (new:Fact {id: f.id, text: f.text, slot: f.slot,
                  validFrom: datetime(f.validFrom), recordedAt: ep.at,
                  embedding: vector(f.embedding, 4, FLOAT32)})-[:FROM]->(ep)
WITH ep, new, e, f
CALL (ep, e) { UNWIND e.mentions AS key MATCH (n:Entity {key: key}) CREATE (ep)-[:MENTIONS]->(n) }
CALL (new, f) { UNWIND f.about AS key MATCH (n:Entity {key: key}) CREATE (new)-[:ABOUT]->(n) }
MATCH (old:Fact {slot: new.slot}) WHERE old <> new AND old.closedAt IS NULL
SET old.validTo = new.validFrom, old.closedAt = new.recordedAt
CREATE (new)-[:SUPERSEDES]->(old)
RETURN new.text AS current, old.text AS replaced, old.validTo AS validTo, old.closedAt AS closedAt
Parameters
Recorded result
currentreplacedvalidToclosedAt
"Ada reviews each relate release.""Grace reviews each relate release.""2026-03-10T00:00:00Z""2026-03-20T09:00:00Z"

1 row · nodes created: 2, relationships created: 6, properties set: 13, labels added: 2

Recall

A recall takes the question's embedding, finds the nearest current facts, and brings back what each is about, the episode it came from, and what it replaced. The question is "Who reviews relate releases?".

Cypher
MATCH (f:Fact)
SEARCH f IN (VECTOR INDEX fact_embedding FOR $vector WHERE f.closedAt IS NULL LIMIT $k) SCORE AS score
MATCH (f)-[:FROM]->(ep:Episode)
RETURN f.text AS memory, round(score, 4) AS score,
       COLLECT { MATCH (f)-[:ABOUT]->(e:Entity) RETURN e.name ORDER BY e.name } AS about,
       ep.kind AS learnedFrom, ep.text AS evidence,
       COLLECT { MATCH (f)-[:SUPERSEDES]->(old:Fact) RETURN old.text } AS replaced
ORDER BY score DESC
Parameters
Recorded result
memoryscoreaboutlearnedFromevidencereplaced
"Ada reviews each relate release."0.999["Ada", "relate"]"tool_call""read_file CODEOWNERS: release/ @ada"["Grace reviews each relate release."]
"Noor triages new relate issues on Mondays."0.6178["Noor", "relate"]"message""Noor goes through new relate issues every Monday."[]

2 rows

Grace's fact is the nearest by meaning, and it does not come back: the filter inside the search drops closed facts before the LIMIT. The answer names the tool call it rests on, so an agent can quote its evidence.

Ask what the agent believed

The two time axes answer two questions about 15 March: who reviewed releases then, as the agent knows now, and who the agent thought did, at the time.

Cypher
MATCH (f:Fact {slot: $slot})
WITH datetime($on) AS t, collect(f) AS facts
RETURN [x IN facts WHERE x.validFrom <= t AND (x.validTo IS NULL OR t < x.validTo) | x.text] AS trueOn,
       [x IN facts WHERE x.recordedAt <= t AND (x.closedAt IS NULL OR t < x.closedAt) | x.text] AS believedOn
Parameters
Recorded result
trueOnbelievedOn
["Ada reviews each relate release."]["Grace reviews each relate release."]

1 row

The answers differ, and both are right. That is the audit trail for "why did the agent ask Grace on 15 March?".

The slot's two facts, with their four times, show why on both axes. Ada's fact holds from 10 March, but the agent wrote it only on 20 March, and until then it still believed Grace's:

Cypher
MATCH (f:Fact {slot: $slot})
RETURN f.text AS fact, f.validFrom AS validFrom, f.validTo AS validTo,
       f.recordedAt AS recordedAt, f.closedAt AS closedAt
ORDER BY f.recordedAt
Parameters
Recorded result
factvalidFromvalidTorecordedAtclosedAt
"Grace reviews each relate release.""2026-01-05T00:00:00Z""2026-03-10T00:00:00Z""2026-03-02T10:00:00Z""2026-03-20T09:00:00Z"
"Ada reviews each relate release.""2026-03-10T00:00:00Z"null"2026-03-20T09:00:00Z"null

2 rows

The slot-facts cell's recorded rows, 2 rows, each on its valid time (validFrom to validTo) and its record time (recordedAt to closedAt); null is an open end.

factvalidFromvalidTorecordedAtclosedAt
Grace reviews each relate release.2026-01-05T00:00:00Z2026-03-10T00:00:00Z2026-03-02T10:00:00Z2026-03-20T09:00:00Z
Ada reviews each relate release.2026-03-10T00:00:00Znull2026-03-20T09:00:00Znull
The slot-facts cell's rows on both time axes, as recorded2 rows of the slot-facts cell on a time axis from 2026-01-05T00:00:00Z to 2026-03-20T09:00:00Z, each with its valid time (validFrom to validTo) above its record time (recordedAt to closedAt); null is an open end. Grace reviews each relate release.: validFrom 2026-01-05T00:00:00Z, validTo 2026-03-10T00:00:00Z; recordedAt 2026-03-02T10:00:00Z, closedAt 2026-03-20T09:00:00Z. Ada reviews each relate release.: validFrom 2026-03-10T00:00:00Z, validTo null; recordedAt 2026-03-20T09:00:00Z, closedAt null.valid time: validFrom to validTorecord time: recordedAt to closedAtGrace reviews each relate release.Grace reviews each relate r…Ada reviews each relate release.Ada reviews each relate rel…2026-01-152026-02-122026-03-12UTCGrace reviews each relate release.: validFrom 2026-01-05T00:00:00Z, validTo 2026-03-10T00:00:00Z; recordedAt 2026-03-02T10:00:00Z, closedAt 2026-03-20T09:00:00ZAda reviews each relate release.: validFrom 2026-03-10T00:00:00Z, validTo null; recordedAt 2026-03-20T09:00:00Z, closedAt null
The slot-facts cell's rows on both time axes, as recorded2 rows of the slot-facts cell on a time axis from 2026-01-05T00:00:00Z to 2026-03-20T09:00:00Z, each with its valid time (validFrom to validTo) above its record time (recordedAt to closedAt); null is an open end. Grace reviews each relate release.: validFrom 2026-01-05T00:00:00Z, validTo 2026-03-10T00:00:00Z; recordedAt 2026-03-02T10:00:00Z, closedAt 2026-03-20T09:00:00Z. Ada reviews each relate release.: validFrom 2026-03-10T00:00:00Z, validTo null; recordedAt 2026-03-20T09:00:00Z, closedAt null.valid time: validFrom to validTorecord time: recordedAt to closedAtGrace reviews each relate release.Grace reviews each relate r…Ada reviews each relate release.Ada reviews each relate rel…2026-01-152026-02-122026-03-12UTCGrace reviews each relate release.: validFrom 2026-01-05T00:00:00Z, validTo 2026-03-10T00:00:00Z; recordedAt 2026-03-02T10:00:00Z, closedAt 2026-03-20T09:00:00ZAda reviews each relate release.: validFrom 2026-03-10T00:00:00Z, validTo null; recordedAt 2026-03-20T09:00:00Z, closedAt null
The slot-facts cell's rows on both time axes, as recorded

Live: the drawing reads its rows again from each run of the statement it draws on the page's database in your browser, so an edit to the statement or its parameters redraws it; Tab reaches the marks and the arrow keys move along a lane and between lanes, each mark read out with its values; where the page names an as-of statement, a time slider runs that statement again with the time as its parameter and lights up the rows it returns.

Serve it to the agent

Recall needs only reads, so an agent that recalls gets the memory store over MCP opened read-only, and only the process that writes memory holds it writable:

relate serve --mcp stdio --database ./memory.rl --read-only

To allow writes, remove --read-only. Opened read-only, the server lists read_cypher and get_schema; the recall statement runs through read_cypher with vector and k as its parameters. Connect over MCP has the block for each host.

Verify

Each slot holds at most one current fact. This check returns no rows while that holds:

Cypher
MATCH (f:Fact) WHERE f.closedAt IS NULL
WITH f.slot AS slot, count(*) AS current WHERE current > 1
RETURN slot, current
Recorded result
| slot | current |
|------|---------|
0 rows

If it goes wrong

A write fails with semantic error: ConstraintValidationFailed: `entity_key` uniqueness. Two writes created the same entity. Write entities with MERGE (e:Entity {key: $key}) so a repeat finds the existing node.

A write fails with type error: vector() dimension does not match list. An embedding's length differs from the one the statement names. Make the vector(…, <dimensions>, FLOAT32) call and the index's vector.dimensions match your model.

Recall fails with type error: VectorIndexDimensionalityMismatch. The question's vector has a different length from the index. Embed the question with the same model as the facts.

Recall returns fewer than k rows. The f.closedAt IS NULL filter sits after SEARCH, so it filters the k rows the index already chose. Inside the search, on a property declared with WITH [...], it runs before the LIMIT.

Recall returns closed facts. The search has no WHERE f.closedAt IS NULL. Put it inside the search's parentheses.