Use relate with the Vercel AI SDK
Back a Vercel AI SDK tool with relate from Node.js, behind a read-only guard, so a model can query the graph and can't change it.
nodeagents
Before you start: Embed relate in a Node.js app
This guide shows you how to back a Vercel AI SDK tool with relate from Node.js, behind a read-only guard. The tool's handle 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, so the engine refuses every write on it. The tool also plans each statement with EXPLAIN first and runs only reads. The model writes the Cypher; relate decides what touches the graph.
Prerequisites
- Node.js 22 or later (the AI SDK's
ai7 and@ai-sdk/anthropic4 require it), with the relate client, the AI SDK and Zod:npm install @relate/relate ai zod. The AI SDK code on this page was checked againstai7.0.165 andzod4.6.7, as of 2026-11-07. - For the model call in Give the tool to a model only: a provider package and its credential, such as
@ai-sdk/anthropic(checked against 4.0.99, as of 2026-11-07) withANTHROPIC_API_KEYin the environment. Everything before that runs without one.
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 tool, readGraph, opens its own read-only handle on that store. The last block calls the tool the way the AI SDK does, with no model, and then tries a write directly on the read-only handle. Its final line counts the people on a new read-only handle:
import { Database, RelateError } from "@relate/relate";
import { tool } from "ai";
import { z } from "zod";
// Setup: the app that owns ./team writes the team graph once, on a writable handle.
const owner = Database.open("./team").connect();
if (owner.query("MATCH (p:Person) RETURN count(p) AS n").rows[0].n === 0) {
owner.query(`
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)`);
}
// The tool's own handle: read-only, so the engine refuses every write on it.
const graph = Database.open("./team", { readOnly: true }).connect();
const MAX_ROWS = 50;
export const readGraph = tool({
description:
"Run one read-only Cypher statement against the team graph (Person and Project nodes; WORKS_ON, MAINTAINS and KNOWS relationships) and return its rows.",
inputSchema: z.object({
statement: z.string().describe("One Cypher statement that only reads"),
params: z.record(z.string(), z.unknown()).default({}).describe("Values for the statement's $parameters"),
}),
execute: async ({ statement, params }) => {
try {
// EXPLAIN plans the statement without running it; query type "r" is a pure read.
const plan = graph.query(`EXPLAIN ${statement}`, params);
if (plan.queryType !== "r") {
return { error: `refused: the statement's query type is "${plan.queryType}", and this tool runs only reads ("r")` };
}
const result = graph.query(statement, params);
// Cypher integers outside ±(2^53 − 1) arrive as bigint, which JSON cannot carry.
const rows = JSON.parse(JSON.stringify(result.rows.slice(0, MAX_ROWS), (_, v) => (typeof v === "bigint" ? v.toString() : v)));
return { columns: result.columns, rows, truncated: result.rows.length > MAX_ROWS };
} catch (e) {
if (e instanceof RelateError) return { error: e.message, code: e.code };
throw e;
}
},
});
// Check: call the tool as the AI SDK does, without a model.
const call = { toolCallId: "check", messages: [] };
console.log(JSON.stringify(await readGraph.execute({
statement: "MATCH (p:Person)-[w:WORKS_ON]->(:Project {name: $project}) RETURN p.name AS name, w.since AS since ORDER BY since",
params: { project: "relate" },
}, call)));
console.log(JSON.stringify(await readGraph.execute({
statement: "MATCH (p:Person {name: $name}) DETACH DELETE p",
params: { name: "Ada" },
}, call)));
try {
graph.query("CREATE (:Person {name: 'Mallory'})");
} catch (e) {
console.log(e.gqlStatus, e.message);
}
const check = Database.open("./team", { readOnly: true }).connect();
console.log("people:", check.query("MATCH (p:Person) RETURN count(p) AS people").rows[0].people);{"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\")"}
42N18 semantic error: WriteOnReadOnlyAccessDatabase: No write operations are allowed on this database. The database is in read-only mode on this Neo4j instance.
people: 3The read returns its rows. The delete never runs: the plan says it writes, so the tool refuses it. The third line is the engine's own refusal of a write sent straight to the read-only handle, past the tool's check. The count shows all three people, Ada included.
Each layer of the guard refuses something different:
| Layer | Refuses | Enforced by |
|---|---|---|
Database.open(path, { readOnly: true }) | Every write and checkpoint on that handle; a store that doesn't exist | The engine |
The EXPLAIN check (queryType 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 |
The tool returns an engine error as data, { error, code }, so the model reads the message and can correct its statement. A read-only handle sees the graph as it was when the handle opened, so commits the owner makes afterwards appear once the tool opens a new handle.
A narrower tool skips model-written Cypher altogether: a fixed statement whose inputSchema takes only the values, such as a project name for $project. Keep the read-only handle either way.
Replay the calls over MCP
relate's MCP server offers the same kind of tool without code. relate serve --mcp stdio --read-only lists read_cypher, which plans each statement with EXPLAIN and refuses one whose plan writes, and get_schema, which reads the labels and relationship types from the store instead of from a description written by hand. Use relate with the Claude Agent SDK registers that server with an agent framework.
The trace below replays this page's read as the tools/call requests an agent host sends to that server. It is a replay of the calls, not a transcript of a model run: no model chose them. It runs in your browser, on an in-memory store holding the team graph from the setup block:
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 program's first check call, with its statement and params, as a cell:
MATCH (p:Person)-[w:WORKS_ON]->(:Project {name: $project}) RETURN p.name AS name, w.since AS since ORDER BY sinceParameters
| name | since |
|---|---|
| "Grace" | 2023 |
| "Ada" | 2024 |
2 rows
| name | since | |---------|-------| | "Grace" | 2023 | | "Ada" | 2024 | 2 rows
The trace sends the schema request, then that read:
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 2: get_schema. The labels, property keys and relationship types that readGraph's description names by hand, read from the store.
{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"get_schema","arguments":{}}}| Label | Nodes | Properties | Relationships out |
|---|---|---|---|
| Person | 3 | name (String), role (String) | WORKS_ON to Project; KNOWS to Person; MAINTAINS to Project |
| Project | 2 | language (String), name (String) | - |
Step 2 of 2: read_cypher. The statement and params of the program's first readGraph.execute call, sent as read_cypher.
{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"read_cypher","arguments":{"statement":"MATCH (p:Person)-[w:WORKS_ON]->(:Project {name: $project}) RETURN p.name AS name, w.since AS since ORDER BY since","parameters":{"project":"relate"}}}}| name | since |
|---|---|
| Grace | 2023 |
| Ada | 2024 |
The rows match cell works-on'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.
The rows are the ones readGraph returned, in the server's reply instead of the tool's { columns, rows, truncated }. The delete the program sends is refused by read_cypher too, with the server's own message; the Claude Agent SDK page replays that refusal.
Give the tool to a model
Add the model call to graph-tool.mjs in place of the check block. This part needs the provider credential:
import { anthropic } from "@ai-sdk/anthropic";
import { generateText, isStepCount } from "ai";
const { text } = await generateText({
model: anthropic("claude-opus-5-5"),
tools: { readGraph },
stopWhen: isStepCount(5),
prompt: "Who works on the relate project, and since when?",
});
console.log(text);stopWhen: isStepCount(5) lets the model call the tool, read the rows and answer, in at most five steps.
Verify
The program's last line already counts the people on a fresh read-only handle. The same check from the CLI, read-only, in the directory the program ran in:
relate --database ./team --read-only --mode envelope "MATCH (p:Person) RETURN count(p) AS people"{"columnTypes":["INTEGER"],"columns":["people"],"notifications":[],"rows":[{"people":3}],"truncated":false}If it goes wrong
| Message | Cause | Remedy |
|---|---|---|
semantic error: WriteOnReadOnlyAccessDatabase: No write operations are allowed on this database. The database is in read-only mode on this Neo4j instance. (RelateError, gqlStatus 42N18) | A write reached the read-only handle | Expected: the guard held. Writes belong on a separate, writable handle that the app owns. |
internal error (RelateError, stage internal) from Database.open | The read-only open found no store at the path, or found a single-file .rl store that a writable handle holds | Create the store first, or fix the path. To read beside a writer, use a directory store. |
TypeError: Do not know how to serialize a BigInt | A row holds an integer outside ±(2^53 − 1), which the client returns as a bigint | Convert bigint values before returning them, as the JSON.stringify replacer in readGraph does. |
| The tool's answers miss recent writes (no error) | The read-only handle sees the graph as it was when it opened | Open a new read-only handle per request, or on a schedule. |
Related
- Embed relate in a Node.js app: the client's handles, parameters, transactions, and errors.
- Keep an agent safe: read-only opens, safe mode, backups, and the permission matrix.
- Errors: the read-only refusal and every other engine error.
- Tool-call provenance: a worked example of recording what an agent's tools did.