Docs
View as markdown

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 is relate-db; the import is import relate. The LangGraph code on this page was checked against langgraph 1.2.20 and langchain-core 1.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.

Data model: episodes attach to the team graphEach 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.Episodeseq · textConversationidPersonname · roleProjectnamelanguageIN_CONVERSATIONMENTIONSMENTIONSWORKS_ON
Data model: episodes attach to the team graphEach 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.Episodeseq · textConversationidPersonname · roleProjectnamelanguageIN_CONVERSATIONMENTIONSMENTIONSWORKS_ON
Data model: episodes attach to the team graph

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:

memory_graph.py
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 under ainvoke it runs synchronous nodes there too. So Memory opens the store on a dedicated thread and sends every statement there. The same tools then work under invoke, ainvoke and ToolNode.
  • remember merges one Conversation per LangGraph thread id, numbers the episode, and links it to every node whose name it mentions. Every value goes in as a $parameter.
  • recall reads the episodes that mention a subject, oldest first, then follows WORKS_ON to 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:

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

The write_episode node of the first turn, in thread standup-monday, calls remember with these values:

Cypher
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
Recorded result
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:

Cypher
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
Recorded result
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:

Cypher
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
Recorded result
conversationtext
"standup-monday""Ada fixed the CSV import bug in relate."
"standup-tuesday""Grace will review the relate release notes."

2 rows

Cypher
MATCH (x {name: $name})<-[w:WORKS_ON]-(p:Person)
RETURN p.name AS person, w.since AS since
ORDER BY since
Parameters
Recorded result
personsince
"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"}}}}
conversationtext
standup-mondayAda fixed the CSV import bug in relate.
standup-tuesdayGrace 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"}}}}
personsince
Grace2023
Ada2024

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

MessageCauseRemedy
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 ainvokeRoute 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 itKeep 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 $parametersPass every parameter the statement names in the params dict.

The errors reference lists the Python exception class for each engine error.