---
title: Use relate with the Vercel AI SDK
description: "relate, the embedded Cypher graph database (relatedb.ai): 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."
type: how-to
surfaces:
  - node
  - agents
status: stable
verified: 0.1.2
verified_by:
  - cypher
  - node
samples:
  - https://relatedb.ai/docs/guides/integrations/vercel-ai-sdk/samples/graph-tool.json
canonical_url: https://relatedb.ai/docs/guides/integrations/vercel-ai-sdk/
prerequisites:
  - get-started/node
related:
  - get-started/node
  - guides/agents/agent-safety
  - reference/errors#engine-write-on-read-only
  - cookbook/tool-call-provenance
  - guides/integrations/openai-agents-sdk
  - guides/integrations/mastra
last_updated: 2026-11-09
---

# 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.

> Verified against relate 0.1.2. If your `relate --version` is newer, read https://relatedb.ai/docs/reference/changelog/#since-0.1.2

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](https://relatedb.ai/docs/reference/glossary/#store) [read-only](https://relatedb.ai/docs/reference/glossary/#read-only), 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 `ai` 7 and `@ai-sdk/anthropic` 4 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 against `ai` 7.0.165 and `zod` 4.6.7, as of 2026-11-07.
- For the model call in [Give the tool to a model](#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) with `ANTHROPIC_API_KEY` in 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:

```js run name=graph-tool title="graph-tool.mjs"
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);
```

```text
{"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: 3
```

The 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](https://relatedb.ai/docs/guides/integrations/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:

```cypher run name=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)
```

```table
(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:

```json data=project
{"project": "relate"}
```

```cypher run name=works-on params=project
MATCH (p:Person)-[w:WORKS_ON]->(:Project {name: $project}) RETURN p.name AS name, w.since AS since ORDER BY since
```

```table
| name    | since |
|---------|-------|
| "Grace" | 2023  |
| "Ada"   | 2024  |
2 rows
```

The trace sends the schema request, then that read:

```json data=mcp-calls
{"steps": [
  {"tool": "get_schema",
   "note": "The labels, property keys and relationship types that `readGraph`'s description names by hand, read from the store.",
   "arguments": {}},
  {"tool": "read_cypher",
   "note": "The statement and `params` of the program's first `readGraph.execute` call, sent as `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"}}}
]}
```

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.

```json
{"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`.

```json
{"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](https://relatedb.ai/docs/guides/integrations/claude-agent-sdk/#replay-the-agents-calls) 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:

```js
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:

```bash
relate --database ./team --read-only --mode envelope "MATCH (p:Person) RETURN count(p) AS people"
```

```text
{"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](https://relatedb.ai/docs/get-started/node/): the client's handles, parameters, transactions, and errors.
- [Keep an agent safe](https://relatedb.ai/docs/guides/agents/agent-safety/): read-only opens, safe mode, backups, and the permission matrix.
- [Errors](https://relatedb.ai/docs/reference/errors/#engine-write-on-read-only): the read-only refusal and every other engine error.
- [Tool-call provenance](https://relatedb.ai/cookbook/tool-call-provenance/): a worked example of recording what an agent's tools did.

## Terms used on this page

- **store**: A relate database: a directory, a single `.rl` file, or `:memory:`, opened with `--database`.
- **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.
