Use relate with LangGraph
Give a LangGraph graph long-term memory in a relate store from Python, with one node that writes each episode and one that recalls it.
pythonagents
Before you start: Embed relate in a Python app
This guide shows you how to give a LangGraph graph long-term memory in a relate store A relate database: a directory, a single .rl file, or :memory:, opened with --database. Read more in the glossary, from Python. LangGraph's checkpointer keeps each conversation's state; relate keeps what should outlive the conversation, as a graph of episodes linked to the people and projects they mention. Two LangChain tools wrap the relate client, and two nodes call them: one writes an episode, the other recalls what memory holds. relate is reached through those tools, not as LangGraph's BaseStore or as its checkpointer. A BaseStore gets items by namespace and key, while the tools let recall follow the graph's relationships.
Prerequisites
- Python 3.10 or later, with the relate client and LangGraph:
pip install relate-db langgraph. The package isrelate-db; the import isimport relate. The LangGraph code on this page was checked againstlanggraph1.2.20 andlangchain-core1.7.6, as of 2026-11-07. - No model and no credential. The nodes call the tools directly, so the program runs offline; Hand the tools to a model shows where a model fits.
Lay out the memory graph
Memory attaches to the small team graph the Get started pages use. Each turn becomes an Episode node in a Conversation, one per LangGraph thread id. The episode MENTIONS the people and projects it names, so recall can follow the team graph's own relationships to facts no episode stated.
This is a reduced form of the model in Model agent memory: the thread id sits on a Conversation node instead of in Episode.session, an episode's seq takes the place of its id and at, and the team graph's Person and Project nodes stand in for Entity. Facts, their two time axes and supersession are left out; that guide adds them for an agent that has to track what is currently true.
In words: Each Episode is IN_CONVERSATION of one Conversation, keyed by the LangGraph thread id. An Episode MENTIONS the Person and Project nodes it names. The team graph's WORKS_ON relationships connect people to projects, so recall can add what no episode said.
Write the program
The program opens ./memory.rl, a single-file store, for writing, and seeds the team graph on its first run. It defines the remember and recall tools and a two-node graph, then runs two turns in two conversations and prints what memory holds about relate. The finally closes the store even when a node raises. The last lines read the episodes back on a new read-only An open mode (--read-only) that never writes, checkpoints or migrates the store; writes fail, and a directory store can be read beside its writer. Read more in the glossary connection:
from concurrent.futures import ThreadPoolExecutor
from typing import TypedDict
import relate
from langchain_core.runnables import RunnableConfig
from langchain_core.tools import tool
from langgraph.checkpoint.memory import InMemorySaver
from langgraph.graph import END, START, StateGraph
TEAM = """
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)
"""
class Memory:
"""A relate store that one dedicated thread opens and runs every statement on."""
def __init__(self, path: str):
self._thread = ThreadPoolExecutor(max_workers=1)
self._conn = self._thread.submit(relate.connect, path).result()
def run(self, statement: str, params: dict | None = None) -> list[dict]:
def execute():
return [dict(row) for row in self._conn.execute(statement, params or {}).fetchall()]
return self._thread.submit(execute).result()
def close(self) -> None:
self._thread.submit(self._conn.close).result()
self._thread.shutdown()
# Writable on purpose: the graph's nodes write episodes.
memory = Memory("./memory.rl")
if memory.run("MATCH (p:Person) RETURN count(p) AS n")[0]["n"] == 0:
memory.run(TEAM)
@tool
def remember(conversation: str, text: str, mentions: list[str]) -> int:
"""Store one episode and link it to the people and projects it names."""
rows = memory.run(
"""
MERGE (c:Conversation {id: $conversation})
WITH c
OPTIONAL MATCH (earlier:Episode)
WITH c, count(earlier) AS seen
CREATE (e:Episode {seq: seen + 1, text: $text})-[:IN_CONVERSATION]->(c)
WITH e
UNWIND $mentions AS name
MATCH (x {name: name})
MERGE (e)-[:MENTIONS]->(x)
RETURN count(x) AS linked
""",
{"conversation": conversation, "text": text, "mentions": mentions},
)
return rows[0]["linked"] if rows else 0
@tool
def recall(name: str) -> list[str]:
"""Return what memory holds about a person or project, oldest episode first."""
episodes = memory.run(
"""
MATCH (x {name: $name})<-[:MENTIONS]-(e:Episode)-[:IN_CONVERSATION]->(c:Conversation)
RETURN c.id AS conversation, e.text AS text
ORDER BY e.seq
""",
{"name": name},
)
people = memory.run(
"""
MATCH (x {name: $name})<-[w:WORKS_ON]-(p:Person)
RETURN p.name AS person, w.since AS since
ORDER BY since
""",
{"name": name},
)
return [f"[{r['conversation']}] {r['text']}" for r in episodes] + [
f"{r['person']} has worked on {name} since {r['since']}" for r in people
]
class State(TypedDict):
message: str
mentions: list[str]
subject: str
context: list[str]
def write_episode(state: State, config: RunnableConfig) -> dict:
remember.invoke({
"conversation": config["configurable"]["thread_id"],
"text": state["message"],
"mentions": state["mentions"],
})
return {}
def recall_context(state: State) -> dict:
return {"context": recall.invoke({"name": state["subject"]})}
builder = StateGraph(State)
builder.add_node("write_episode", write_episode)
builder.add_node("recall_context", recall_context)
builder.add_edge(START, "write_episode")
builder.add_edge("write_episode", "recall_context")
builder.add_edge("recall_context", END)
graph = builder.compile(checkpointer=InMemorySaver())
try:
graph.invoke(
{"message": "Ada fixed the CSV import bug in relate.", "mentions": ["Ada", "relate"], "subject": "relate"},
{"configurable": {"thread_id": "standup-monday"}},
)
state = graph.invoke(
{"message": "Grace will review the relate release notes.", "mentions": ["Grace", "relate"], "subject": "relate"},
{"configurable": {"thread_id": "standup-tuesday"}},
)
for line in state["context"]:
print(line)
finally:
memory.close()
# Check: the episodes outlive the program's connection.
reader = relate.connect("./memory.rl", read_only=True)
episodes = reader.execute(
"MATCH (e:Episode)-[:IN_CONVERSATION]->(c:Conversation) RETURN c.id AS conversation, e.seq AS seq ORDER BY seq"
).fetchall()
print(f"{len(episodes)} episodes:", ", ".join(f"{row['seq']} in {row['conversation']}" for row in episodes))
reader.close()[standup-monday] Ada fixed the CSV import bug in relate. [standup-tuesday] Grace will review the relate release notes. Grace has worked on relate since 2023 Ada has worked on relate since 2024 2 episodes: 1 in standup-monday, 2 in standup-tuesday
The second turn recalls both episodes, though each came from a different conversation. The two lines after them come from the team graph, not from any episode. The parts that matter:
- One thread owns the connection. A relate connection is thread-affine: only the thread that opened it may use it. LangGraph runs a
ToolNode's calls on worker threads, and underainvokeit runs synchronous nodes there too. SoMemoryopens the store on a dedicated thread and sends every statement there. The same tools then work underinvoke,ainvokeandToolNode. remembermerges oneConversationper LangGraph thread id, numbers the episode, and links it to every node whosenameit mentions. Every value goes in as a$parameter.recallreads the episodes that mention a subject, oldest first, then followsWORKS_ONto who works on it.- The mentions are given, not extracted. In a real agent a model picks them out of the message; in this program they're fixed, so the run is the same every time.
Step through the nodes' calls
Each node reaches relate through one statement per call, with its values as parameter A named value a statement refers to as $name and the caller binds separately, such as with --param name=value on the CLI. Read more in the glossary. The cells below hold the program's seed and the statements its two tools run, with the values of the program's two turns; they run in your browser, on an in-memory store instead of ./memory.rl. First the team graph:
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)(no rows) · nodes created: 5, relationships created: 5, properties set: 13, labels added: 5
The write_episode node of the first turn, in thread standup-monday, calls remember with these values:
MERGE (c:Conversation {id: $conversation})
WITH c
OPTIONAL MATCH (earlier:Episode)
WITH c, count(earlier) AS seen
CREATE (e:Episode {seq: seen + 1, text: $text})-[:IN_CONVERSATION]->(c)
WITH e
UNWIND $mentions AS name
MATCH (x {name: name})
MERGE (e)-[:MENTIONS]->(x)
RETURN count(x) AS linkedParameters
| linked |
|---|
| 2 |
1 row · nodes created: 2, relationships created: 3, properties set: 3, labels added: 2
| linked | |--------| | 2 | 1 row · nodes created: 2, relationships created: 3, properties set: 3, labels added: 2
The second turn's, in thread standup-tuesday, runs the same statement:
MERGE (c:Conversation {id: $conversation})
WITH c
OPTIONAL MATCH (earlier:Episode)
WITH c, count(earlier) AS seen
CREATE (e:Episode {seq: seen + 1, text: $text})-[:IN_CONVERSATION]->(c)
WITH e
UNWIND $mentions AS name
MATCH (x {name: name})
MERGE (e)-[:MENTIONS]->(x)
RETURN count(x) AS linkedParameters
| linked |
|---|
| 2 |
1 row · nodes created: 2, relationships created: 3, properties set: 3, labels added: 2
| linked | |--------| | 2 | 1 row · nodes created: 2, relationships created: 3, properties set: 3, labels added: 2
Then its recall_context node calls recall for relate, which runs two reads:
MATCH (x {name: $name})<-[:MENTIONS]-(e:Episode)-[:IN_CONVERSATION]->(c:Conversation)
RETURN c.id AS conversation, e.text AS text
ORDER BY e.seqParameters
| conversation | text |
|---|---|
| "standup-monday" | "Ada fixed the CSV import bug in relate." |
| "standup-tuesday" | "Grace will review the relate release notes." |
2 rows
| conversation | text | |-------------------|-----------------------------------------------| | "standup-monday" | "Ada fixed the CSV import bug in relate." | | "standup-tuesday" | "Grace will review the relate release notes." | 2 rows
MATCH (x {name: $name})<-[w:WORKS_ON]-(p:Person)
RETURN p.name AS person, w.since AS since
ORDER BY sinceParameters
| person | since |
|---|---|
| "Grace" | 2023 |
| "Ada" | 2024 |
2 rows
| person | since | |---------|-------| | "Grace" | 2023 | | "Ada" | 2024 | 2 rows
These rows are the four lines the program prints before its check.
The same statements reach relate as MCP tool calls when an agent host serves the store with relate serve --mcp stdio instead of opening it in-process; Use relate with the Claude Agent SDK registers that server. The trace below replays the four calls in order as the tools/call requests such a host sends, on the team graph the first cell builds. The writes go to write_cypher, which a server lists only when it opens the store without --read-only, as this program opens ./memory.rl writable. It is a replay of the program's calls, not a transcript of a model run: no model chose them. The first turn's recall_context node runs the same two reads and sees one episode; the trace keeps the second turn's, the one the program prints.
The trace starts on the database the cells up to team build. Each step below ran when the page was recorded, one after another on that database, through the mapping the MCP tryout uses; each result is what relate serve's tool returns for that call, built from the engine's result. No agent and no server took part in the recording.
Step 1 of 4: write_cypher. First turn, write_episode: the remember statement with the monday values.
{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"write_cypher","arguments":{"statement":"MERGE (c:Conversation {id: $conversation}) WITH c OPTIONAL MATCH (earlier:Episode) WITH c, count(earlier) AS seen CREATE (e:Episode {seq: seen + 1, text: $text})-[:IN_CONVERSATION]->(c) WITH e UNWIND $mentions AS name MATCH (x {name: name}) MERGE (e)-[:MENTIONS]->(x) RETURN count(x) AS linked","parameters":{"conversation":"standup-monday","text":"Ada fixed the CSV import bug in relate.","mentions":["Ada","relate"]}}}}Counters: nodes_created 2, relationships_created 3, properties_set 3, labels_added 2; rows is empty, as write_cypher returns counters.
The rows match cell remember-monday's recording.
Step 2 of 4: write_cypher. Second turn, write_episode: the same statement with the tuesday values, in a new conversation.
{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"write_cypher","arguments":{"statement":"MERGE (c:Conversation {id: $conversation}) WITH c OPTIONAL MATCH (earlier:Episode) WITH c, count(earlier) AS seen CREATE (e:Episode {seq: seen + 1, text: $text})-[:IN_CONVERSATION]->(c) WITH e UNWIND $mentions AS name MATCH (x {name: name}) MERGE (e)-[:MENTIONS]->(x) RETURN count(x) AS linked","parameters":{"conversation":"standup-tuesday","text":"Grace will review the relate release notes.","mentions":["Grace","relate"]}}}}Counters: nodes_created 2, relationships_created 3, properties_set 3, labels_added 2; rows is empty, as write_cypher returns counters.
The rows match cell remember-tuesday's recording.
Step 3 of 4: read_cypher. Second turn, recall_context: recall's first read, the episodes that mention relate from both conversations, oldest first.
{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"read_cypher","arguments":{"statement":"MATCH (x {name: $name})<-[:MENTIONS]-(e:Episode)-[:IN_CONVERSATION]->(c:Conversation) RETURN c.id AS conversation, e.text AS text ORDER BY e.seq","parameters":{"name":"relate"}}}}| conversation | text |
|---|---|
| standup-monday | Ada fixed the CSV import bug in relate. |
| standup-tuesday | Grace will review the relate release notes. |
The rows match cell episodes's recording.
Step 4 of 4: read_cypher. Second turn, recall_context: recall's second read, who works on relate along the team graph's WORKS_ON, which no episode stated.
{"jsonrpc":"2.0","id":4,"method":"tools/call","params":{"name":"read_cypher","arguments":{"statement":"MATCH (x {name: $name})<-[w:WORKS_ON]-(p:Person) RETURN p.name AS person, w.since AS since ORDER BY since","parameters":{"name":"relate"}}}}| person | since |
|---|---|
| Grace | 2023 |
| Ada | 2024 |
The rows match cell people's recording.
Live: Step sends the next call and Play all sends the rest, each to the page's database in your browser on the store the cells build up to the trace's starting cell, and each step shows the result relate serve's tool returns for that call, built from the engine's live result; a trace that would write twice starts on an emptied database, and the database is emptied again when the trace ends, so the page's cells run on what they write.
Where the remember tool returns linked, each write_cypher reply returns counters: two nodes, the turn's new Conversation and its Episode, and three relationships, IN_CONVERSATION and two MENTIONS.
Hand the tools to a model
remember and recall are LangChain tools, so a chat model can call them itself. Bind them to the model with its bind_tools method and run the calls with LangGraph's ToolNode:
from langgraph.prebuilt import ToolNode
tools = [remember, recall]
model_with_tools = model.bind_tools(tools) # model: any LangChain chat model
tool_node = ToolNode(tools)ToolNode runs each call on a worker thread, which Memory already allows for.
Verify
The program's last line already reads the episodes back on a read-only connection. The same check from the CLI, read-only, in the directory the program ran in:
relate --database ./memory.rl --read-only --mode table "MATCH (e:Episode)-[:IN_CONVERSATION]->(c:Conversation) RETURN c.id AS conversation, e.seq AS seq, e.text AS episode ORDER BY seq"+-------------------+-----+-----------------------------------------------+
| conversation | seq | episode |
+-------------------+-----+-----------------------------------------------+
| "standup-monday" | 1 | "Ada fixed the CSV import bug in relate." |
| "standup-tuesday" | 2 | "Grace will review the relate release notes." |
+-------------------+-----+-----------------------------------------------+Two episodes, one per conversation, in the order they were written. A second run of the program adds two more, numbered 3 and 4.
If it goes wrong
| Message | Cause | Remedy |
|---|---|---|
Relate connection used from a thread other than the one that opened it: connections are thread-affine (DB-API threadsafety = 1). Create the connection on the thread that uses it. (relate.ProgrammingError) | A connection opened on one thread was used from another, such as a ToolNode call or a node under ainvoke | Route every statement through one owning thread, as Memory does. |
storage locked: <path> (relate.OperationalError) | A second process, or a second writable connection, opened ./memory.rl while the program holds it | Keep one writer per store. To read while the program runs, use a directory store (Memory("./memory")) and open it read-only beside the writer. |
semantic error: ParameterMissing: Expected parameter(s): name (relate.ProgrammingError) | A tool ran a statement without one of its $parameters | Pass every parameter the statement names in the params dict. |
The errors reference lists the Python exception class for each engine error.
Related
- Embed relate in a Python app: the client's connections, parameters, transactions, and errors.
- Model agent memory: choosing labels and relationships for what an agent remembers.
- Agent memory: a fuller memory graph, with facts and decisions that supersede older ones.