Use relate with the OpenAI Agents SDK
Back an OpenAI Agents SDK function tool with relate, planning each model-written statement with EXPLAIN on a read-only connection before it runs.
pythonagentsmcp
Before you start: Embed relate in a Python app
This guide shows you how to back an OpenAI Agents SDK function tool with relate, so a model can write Cypher against a graph and can't change it. The tool opens the store A relate database: a directory, a single .rl file, or :memory:, opened with --database. Read more in the glossary 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 on each call, plans the model's statement One unit of Cypher that relate parses, plans and runs, such as a MATCH … RETURN read or a CREATE write; a script holds several, separated by ;. Read more in the glossary with EXPLAIN, and runs it only when the plan The tree of operators the planner chooses to run a statement; EXPLAIN shows it without running, PROFILE runs and adds rows and time per operator. Read more in the glossary says it reads. Engine errors go back to the model as data, so it can correct its statement. Everything before the agent loop runs without a credential.
Prerequisites
- Python 3.10 or later, with the relate client A language package that runs the relate engine inside a program's process: the Python, Node.js, Rust, Java, Go and Swift clients. Read more in the glossary and the SDK:
pip install relate-db openai-agents. The package isrelate-db; the import isimport relate. The SDK code on this page was checked againstopenai-agents0.24.0, as of 2026-11-08. - For Give the tool to an agent only:
OPENAI_API_KEYin the environment. The SDK records a trace of each run and exports it to OpenAI;OPENAI_AGENTS_DISABLE_TRACING=1turns that off. - relate on your
PATHfor Verify and Connect over MCP instead (Install relate).
Write the tool
The program has three parts. A setup block writes the small team graph the Get started pages use into ./team, a directory store, standing in for the app that owns the data. The read_graph tool takes a statement and its parameters from the model. The last block invokes the tool the way the SDK's runner does, with no model, three times: a read, a delete, and a read that forgets its parameter:
import asyncio
import json
import relate
from agents import function_tool
from agents.tool_context import ToolContext
from pydantic import BaseModel, Field
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)
"""
STORE = "./team"
MAX_ROWS = 50
# Setup: the app that owns ./team writes the team graph once, on a writable connection.
with relate.connect(STORE) as owner:
if owner.execute("MATCH (p:Person) RETURN count(p) AS n").single()["n"] == 0:
owner.execute(TEAM)
class Param(BaseModel):
name: str = Field(description="The parameter's name, without the $")
value: str | int | float | bool
@function_tool
def read_graph(statement: str, params: list[Param]) -> str:
"""Run one read-only Cypher statement against the team graph and return its rows as JSON.
The graph has Person and Project nodes and WORKS_ON, MAINTAINS and KNOWS relationships.
Args:
statement: One Cypher statement that only reads.
params: Values for the statement's $parameters.
"""
values = {p.name: p.value for p in params}
try:
# Read-only: the engine refuses every write on this connection.
with relate.connect(STORE, read_only=True) as conn:
# EXPLAIN plans the statement without running it; query type "r" is a pure read.
kind = conn.execute(f"EXPLAIN {statement}", values).consume().query_type
if kind != "r":
return json.dumps({"error": f'refused: the statement\'s query type is "{kind}", and this tool runs only reads ("r")'})
cur = conn.execute(statement, values)
rows = [row.data() for row in cur.fetchmany(MAX_ROWS + 1)]
except relate.Error as e:
return json.dumps({"error": str(e), "gql_status": e.gql_status})
return json.dumps({"columns": cur.columns, "rows": rows[:MAX_ROWS], "truncated": len(rows) > MAX_ROWS}, default=str)
async def call(tool, arguments: dict) -> str:
"""Invoke a function tool the way the Runner does, without a model."""
args = json.dumps(arguments)
ctx = ToolContext(context=None, tool_name=tool.name, tool_call_id="check", tool_arguments=args)
return await tool.on_invoke_tool(ctx, args)
# Check: what the model sees, then three calls.
print(read_graph.name, json.dumps(read_graph.params_json_schema["required"]))
print(asyncio.run(call(read_graph, {
"statement": "MATCH (p:Person)-[w:WORKS_ON]->(:Project {name: $project}) RETURN p.name AS name, w.since AS since ORDER BY since",
"params": [{"name": "project", "value": "relate"}],
})))
print(asyncio.run(call(read_graph, {
"statement": "MATCH (p:Person {name: $name}) DETACH DELETE p",
"params": [{"name": "name", "value": "Ada"}],
})))
print(asyncio.run(call(read_graph, {"statement": "MATCH (p:Person {name: $name}) RETURN p.role AS role", "params": []})))
with relate.connect(STORE, read_only=True) as conn:
print("people:", conn.execute("MATCH (p:Person) RETURN count(p) AS people").single()["people"])read_graph ["statement", "params"]
{"columns": ["name", "since"], "rows": [{"name": "Grace", "since": 2023}, {"name": "Ada", "since": 2024}], "truncated": false}
{"error": "refused: the statement's query type is \"w\", and this tool runs only reads (\"r\")"}
{"error": "semantic error: ParameterMissing: Expected parameter(s): name", "gql_status": "42N02"}
people: 3The read returns its rows. The delete never runs: its plan says it writes, so the tool refuses it. The third call names $name without a value, and the engine's error comes back as the tool's answer with its GQLSTATUS code. The count shows all three people, Ada included. The parts that matter:
paramsis a list ofParam. The SDK builds a strict JSON schema for every function tool, and a strict schema can't describe adictof arbitrary keys. A list of name-and-value objects can, and the tool turns it back into the dict the client takes.- A connection per call. The SDK runs a synchronous tool with
asyncio.to_thread, on a worker thread, and a relate connection belongs to the thread that opened it. Opening inside the tool keeps the open, the plan and the run on one thread, and each call sees every commit the app made before it. callbuilds theToolContextthe runner would pass and awaits the tool'son_invoke_tool, so the check exercises the same argument parsing and validation a model's call goes through.
Each layer of the guard refuses something different:
| Layer | Refuses | Enforced by |
|---|---|---|
relate.connect(path, read_only=True) | Every write and checkpoint on that connection; a path with no store | The engine |
The EXPLAIN check (query_type must be "r") | Writes ("w", "rw") and schema statements ("s"), before they run | The tool |
MAX_ROWS | Rows beyond the first 50, with "truncated": true in the result | The tool |
A narrower tool skips model-written Cypher altogether: a fixed statement whose arguments are only the values, such as a project name for $project. Keep the read-only connection either way.
Give the tool to an agent
Replace the check block at the end of graph_tool.py with an agent and a run. This part needs OPENAI_API_KEY:
from agents import Agent, Runner
team_agent = Agent(
name="Team analyst",
instructions="Answer questions about the team graph with the read_graph tool. Pass every value as a parameter.",
tools=[read_graph],
)
result = Runner.run_sync(team_agent, "Who works on relate, and since when?")
print(result.final_output)Agent uses the SDK's default model unless model names one; in 0.24.0 the default is gpt-5.6-luna, and OPENAI_DEFAULT_MODEL changes it. The runner stops after 10 turns by default (max_turns), so a model that keeps retrying a refused statement ends the run with MaxTurnsExceeded instead of looping.
Connect over MCP instead
The SDK can also reach relate's MCP server, which gives the agent relate's own read tool instead of one you write. MCPServerStdio starts relate serve as a child process when the async with block opens, and stops it when the block ends:
import asyncio
from agents import Agent, Runner
from agents.mcp import MCPServerStdio
async def main():
async with MCPServerStdio(params={
"command": "relate",
"args": ["serve", "--mcp", "stdio", "--database", "./team", "--read-only"],
}) as relate_mcp:
team_agent = Agent(name="Team analyst", instructions="Answer questions about the team graph.", mcp_servers=[relate_mcp])
result = await Runner.run(team_agent, "How many people are on the team?")
print(result.final_output)
asyncio.run(main())To allow writes, remove "--read-only".
Opened read-only, the server lists two tools, read_cypher and get_schema, and leaves out write_cypher; read_cypher takes the Cypher as statement and refuses writes either way. The MCP server reference lists each tool's arguments and refusals.
Verify
The program's last line already counts the people on a read-only connection. The same check from the CLI, read-only, in the directory the program ran in, names them:
relate --database ./team --read-only --mode envelope "MATCH (p:Person) WITH p ORDER BY p.name RETURN collect(p.name) AS people"{"columnTypes":["LIST"],"columns":["people"],"notifications":[],"rows":[{"people":["Ada","Grace","Noor"]}],"truncated":false}Ada is still there: the delete the tool refused never reached the store.
If it goes wrong
| Message | Cause | Remedy |
|---|---|---|
additionalProperties should not be set for object types. This could be because you're using an older version of Pydantic, or because you configured additional properties to be allowed. If you really need this, update the function or output tool to not use a strict schema. (agents.exceptions.UserError, when the tool is defined) | A function tool takes a dict parameter, which a strict schema can't describe | Take a list of name-and-value objects, as Param does. |
Missing credentials. Please pass an `api_key`, `workload_identity`, `admin_api_key`, or set the `OPENAI_API_KEY` or `OPENAI_ADMIN_KEY` environment variable. (openai.OpenAIError) | The runner called the model with no credential set | Set OPENAI_API_KEY. The check block needs none. |
{"error": "semantic error: ParameterMissing: Expected parameter(s): name", "gql_status": "42N02"} (the tool's answer) | The model's statement names a $parameter it didn't pass in params | Nothing to fix in the tool: the model reads the error and calls again with the value. |
storage error: read-only open requires an existing database at <path> (in the tool's error) | STORE names a directory that holds no store; a read-only open never creates one | Create the store with a writable open first, as the setup block does, or fix the path. |
The errors reference lists every engine error with its GQLSTATUS code.
Related
- Embed relate in a Python app: the client's connections, parameters, transactions, and errors.
- Use relate with the Vercel AI SDK: the same guard from Node.js.
- Keep an agent safe: read-only opens, safe mode, backups, and the permission matrix.
- Text to Cypher: checking a model's statement before it runs.
- Graph queries over MCP: the read-only MCP server for agent frameworks and hosts.