Vercel AI SDK + Neo4j Integration
Overview
Vercel AI SDK is a TypeScript-first, provider-agnostic toolkit for building AI-powered applications and agents. It supports streaming, structured output, and multi-step agentic tool loops with a unified interface across OpenAI, Google Gemini, Anthropic, Mistral, and more.
Key Features:
-
generateText/streamTextfor one-shot and streaming LLM calls -
Multi-step agentic loops via
stopWhen: stepCountIs(N)(AI SDK v6+) -
tool()helper withjsonSchema()for type-safe tool definitions (no Zod required) -
Provider-agnostic — swap LLMs with a single environment variable change
-
MCP client support via
@ai-sdk/mcp
Official Resources:
-
Website: ai-sdk.dev
-
Documentation: ai-sdk.dev/docs
-
MCP client docs: ai-sdk.dev/docs/ai-sdk-core/mcp-tools
Architecture
Four extension points on AI SDK v7, with NAMS as the memory backend:
After (current — 4 extension points, AI SDK v7, NAMS, demo app):
Code Examples
There are three projects here. They all use the same memory service, NAMS (Neo4j Agent Memory System).
A) Node.js scripts — notebook/
Five small scripts. Each adds one idea, from a plain database query up to an agent that remembers you between runs.
| Script | What it shows |
|---|---|
|
Talk to Neo4j directly, no AI |
|
An AI agent that queries Neo4j through an MCP server |
|
The same agent plus your own Cypher tools |
|
Memory done by hand: load before the answer, save after |
|
Memory done by the |
Setup and settings: notebook/README.md.
B) Next.js chat app — vercel_Nams_demo/
A chat app that remembers you after a page reload. It can switch between all four memory modes with NAMS_MODE:
NAMS_MODE |
Who takes care of memory |
|---|---|
|
A wrapper around the AI model: adds memories before each answer, saves the turn after |
|
The same wrapper, placed on a model you already have |
|
The model itself, by calling |
|
The app’s own code: loads the saved chat before each answer, saves every turn after |
It can also query your Neo4j database through MCP. Setup: vercel_Nams_demo/README.md.
C) Eve agent — vercel-eve/
A research agent built on eve, Vercel’s framework for backend agents. It answers questions about companies from a Neo4j graph and remembers each user between sessions. Before each turn it looks up what it knows about the user, and after each turn eve’s own hooks save what was said.
cd vercel-eve/industry-research-agent
npm install && cp .env.example .env # add NAMS_API_KEY + OPENAI_API_KEY
npm run chat # starts the local MCP server + a terminal chat
Setup and how it works: vercel-eve/README.md.
eve Agent — vercel-eve/
Persistent, graph-backed memory for eve, Vercel’s open-source framework for durable backend agents. The working project, vercel-eve/industry-research-agent/, implements the repo’s reference agent on eve: recall via dynamic instructions on turn.started, retention via hooks on turn.completed, three MCP servers mounted as read-only connections — Neo4j’s hosted one, NAMS’s own, and a local one you can edit in mcp-server/ — and cross-session recall covered by an eval that discards the transcript between sessions.
cd vercel-eve/industry-research-agent
npm install && cp .env.example .env # add NAMS_API_KEY + OPENAI_API_KEY
npm run chat # local MCP server + terminal chat UI
See vercel-eve/README.md for setup, how to add tools and MCP connections, authentication as the memory boundary, and the known NAMS limits.
Extension Points
1. MCP Integration
The Vercel AI SDK supports MCP via the @ai-sdk/mcp package. createMCPClient (stable since AI SDK v7 / @ai-sdk/mcp@^2) connects to any MCP server over HTTP or SSE transport, and mcpClient.tools() returns a tools object ready for generateText.
npm install @ai-sdk/mcp
import { generateText, stepCountIs } from 'ai';
import { createMCPClient } from '@ai-sdk/mcp';
// Basic auth shown here. The notebook's mcp.mjs also supports a Bearer token
// (MCP_BEARER_TOKEN) for OAuth 2.1 servers — see "MCP Authentication" below.
const creds = Buffer.from(`${process.env.NEO4J_USERNAME}:${process.env.NEO4J_PASSWORD}`)
.toString('base64');
const mcpClient = await createMCPClient({
transport: {
type: 'http',
url: `http://localhost:${process.env.MCP_PORT}/mcp`,
headers: { Authorization: `Basic ${creds}` },
},
});
const mcpTools = await mcpClient.tools(); // get-schema, read-cypher, write-cypher, ...
const { text, steps } = await generateText({
model,
system: 'You are a graph database assistant. Run get-schema first if unfamiliar.',
prompt: 'How many organizations are in the database?',
tools: mcpTools,
stopWhen: stepCountIs(10), // AI SDK v6+ — replaces the removed maxSteps
});
await mcpClient.close();
Example output (node 1-mcp-agent.mjs; tool names depend on the MCP server):
LLM: openai / gpt-5.4-mini
[neo4j-mcp] Connected (basic auth) — tools: get-schema, list-gds-procedures, read-cypher, write-cypher
Agent: neo4j_explorer
Tools: get-schema, list-gds-procedures, read-cypher, write-cypher
Query: How many organizations are in the database?
Result: There are 46,088 organizations in the database.
[Completed in 3 step(s)]
When to use: Start here. Covers most graph queries with zero Cypher knowledge required — the agent uses get-schema + read-cypher autonomously.
2. Direct Neo4j Integration
For queries that need hand-tuned Cypher or access patterns the MCP server doesn’t expose, use the neo4j-driver directly. Custom tools are defined with tool() + jsonSchema() and can be merged with MCP tools in the same generateText call.
npm install neo4j-driver
import { generateText, tool, jsonSchema, stepCountIs } from 'ai';
import neo4j from 'neo4j-driver';
const driver = neo4j.driver(
process.env.NEO4J_URI,
neo4j.auth.basic(process.env.NEO4J_USERNAME, process.env.NEO4J_PASSWORD),
{ disableLosslessIntegers: true }
);
const getInvestments = tool({
description: 'Returns investments made by a company.',
inputSchema: jsonSchema({
type: 'object',
properties: {
company: { type: 'string', description: 'Company or organization name' },
},
required: ['company'],
}),
execute: async ({ company }) => {
const { records } = await driver.executeQuery(
`MATCH (o:Organization)-[:HAS_INVESTOR]->(i)
WHERE o.name = $company
RETURN i.id AS id, i.name AS name, head(labels(i)) AS type`,
{ company },
{ database: process.env.NEO4J_DATABASE }
);
return records.map(r => r.toObject());
},
});
// Merge custom tool with MCP tools — the framework routes each call automatically
const { text } = await generateText({
model,
prompt: 'Which companies did Google invest in?',
tools: { ...mcpTools, getInvestments },
stopWhen: stepCountIs(10),
});
await driver.close();
Example output:
Result: Google has made investments in several notable companies:
- Ionic Security
- Avere Systems
- FlexiDAO
- Cloudflare
- Trifacta
[Completed in 4 step(s)]
When to use: When you need precise Cypher beyond what the MCP server provides, or want to mix domain-specific tools (e.g. custom aggregations, write operations) with MCP tools in a single agent.
3. Persistent Memory — @neo4j-labs/agent-memory client
Memory lives in NAMS, a hosted memory service backed by Neo4j, reached through the low-level @neo4j-labs/agent-memory client. You write the two hooks around generateText yourself:
-
Before hook (
buildContext) — reads the conversation’s short-term context (reflections, recent messages) and searches long-term entities for the query, then injects both into the system prompt -
After hook (
saveInteraction) — saves the user and assistant messages to the conversation, and the answer as a long-term entity so it survives the session
npm install @neo4j-labs/agent-memory
import { MemoryClient } from '@neo4j-labs/agent-memory';
const memoryClient = new MemoryClient({ apiKey: process.env.MEMORY_API_KEY });
const { id: convId } = await memoryClient.shortTerm.createConversation({ userId: DEMO_USER_ID });
async function buildContext(query) {
const ctx = await memoryClient.shortTerm.getContext(convId);
const entities = await memoryClient.longTerm.searchEntities(query, { limit: 5 });
// ...format ctx.reflections, ctx.recentMessages and entities into a MEMORY CONTEXT block
}
async function runWithMemory(query) {
const { text } = await generateText({
model,
system: await buildContext(query), // BEFORE
prompt: query,
tools: mcpTools,
stopWhen: stepCountIs(10),
});
// AFTER
await memoryClient.shortTerm.addMessage(convId, 'user', query);
await memoryClient.shortTerm.addMessage(convId, 'assistant', text);
await memoryClient.longTerm.addEntity(`Research: ${query.slice(0, 60)}`, 'concept', {
description: text.slice(0, 500),
});
return text;
}
workspaceId goes on the MemoryClient config (sent as X-Workspace-Id), not on createConversation().
Example output (two-turn demo; answers abridged):
Memory session: <conversation-id> (workspace: default)
[USER]: I am conducting a competitive analysis of 'Google'. Tell me about their presence in the knowledge graph.
[AGENT]: Google appears in the graph as...
[Memory] Interaction saved to NAMS ✓
[USER]: Based on our conversation, what subsidiaries of the company we discussed appear in the database?
↳ Injecting 1 entity/entities from long-term memory.
↳ Injecting context: 2 messages, 1 entities.
[AGENT]: Based on our analysis of Google, the subsidiaries in the database include...
[Memory] Interaction saved to NAMS ✓
When to use: When you want full control over what is read and written each turn. For the same memory without writing the hooks, use extension point 4.
4. NAMS Provider — @neo4j-labs/nams-ai-provider
This package connects NAMS memory to the AI SDK for you, so you don’t write the load-and-save code yourself. It has four modes. The only difference is who decides when memory is read and saved.
npm install @neo4j-labs/nams-ai-provider
| Mode | Who reads and saves memory | Code |
|---|---|---|
|
A wrapper around every model call |
|
|
The same wrapper, on a model you already have |
|
|
The model, by calling |
|
|
Your code: |
|
All four save to the same place, so you can switch modes without losing memory.
Provider mode (the easiest place to start):
import { createNamsProvider } from '@neo4j-labs/nams-ai-provider';
import { openai } from '@ai-sdk/openai';
import { generateText } from 'ai';
const model = createNamsProvider({
apiKey: process.env.MEMORY_API_KEY,
baseProvider: openai,
scope: { userId: 'user-1' },
}).languageModel('gpt-5.4-mini');
const { text } = await generateText({ model, prompt: 'What did we discuss last time?' });
Middleware mode does the same thing for a model you already have:
const model = createNams({ apiKey }).wrap(openai('gpt-5.4-mini'), { userId: 'user-1' });
Tools mode lets the model decide, and you can see each memory call:
import { createNams, enforceQueryMemory } from '@neo4j-labs/nams-ai-provider';
import { ToolLoopAgent, stepCountIs } from 'ai';
const { tools, close } = await createNams({ apiKey }).toolsWithMcp({ userId: 'user-1' }, mcpConfig);
const agent = new ToolLoopAgent({
model: openai('gpt-5.4-mini'),
tools,
prepareStep: enforceQueryMemory({ graceSteps: 2 }), // make sure it reads memory first
stopWhen: stepCountIs(10),
onFinish: async () => { await close(); },
});
A model can forget to save. The demo doesn’t rely on it for the chat itself: it saves each turn’s text in onFinish through a hooks session (session.onFinish({ prompt })({ text })).
Hooks mode keeps the model out of it. Your code loads the saved chat and saves every turn:
const session = createNams({ apiKey }).hooks({ userId: 'user-1' });
const { text } = await generateText({
model: openai('gpt-5.4-mini'),
messages: [...(await session.loadSession()), { role: 'user', content: prompt }],
onFinish: session.onFinish({ prompt }),
});
Hooks mode saves the conversation only, not separate long-term facts. With package version 0.3.0 it only reloads the first 40 messages of a conversation. See the demo README.
Which mode? Want it to just work: provider. Already have a model: middleware. Want to see memory calls: tools. Want every turn saved no matter what: hooks.
The demo (vercel_Nams_demo/) and the notebook (notebook/4-nams-provider-agent.mjs) both run all four modes.
MCP Authentication
MCP credentials go in the headers option of createMCPClient (HTTP transport). The notebook’s notebook/mcp.mjs and the demo’s lib/neo4j-mcp.ts pick the scheme from env vars:
| Server | Env vars | Header sent |
|---|---|---|
Hosted Aura / NeoCompanion (OAuth 2.1) |
|
|
Self-hosted server behind Basic auth |
|
|
The endpoint is MCP_URL, or http://localhost:${MCP_PORT}/mcp when only MCP_PORT is set. In the notebook, the Basic pair falls back to NEO4J_USERNAME / NEO4J_PASSWORD.
const mcpClient = await createMCPClient({
transport: {
type: 'http',
url: process.env.MCP_URL,
headers: { Authorization: `Bearer ${process.env.MCP_BEARER_TOKEN}` },
},
});
A 401 usually means the wrong scheme rather than wrong credentials. explainMcpError() in both helpers re-probes the endpoint and reports the server’s WWW-Authenticate challenge.
|
Running |
LLM Provider Configuration
The notebook scripts get their model from notebook/providers.mjs: getModel() (scripts 1–3) or getProvider() (script 4, since NAMS provider mode wraps a provider, not a model). The provider is picked by the AI_PROVIDER environment variable, so switching needs no code changes. The Google, Anthropic and Mistral packages are optionalDependencies, so a normal npm install includes them.
| Provider | AI_PROVIDER |
API Key Variable |
|---|---|---|
OpenAI (default) |
|
|
Google Gemini |
|
|
Anthropic Claude |
|
|
Mistral |
|
|
The Next.js demo is OpenAI-only (OPENAI_MODEL). The eve agent routes through Vercel AI Gateway or OpenAI directly (AGENT_MODEL, MODEL_ROUTING).
Challenges and Gaps
| Area | Detail |
|---|---|
JavaScript only |
The Vercel AI SDK has no Python support — all agent code runs in Node.js |
`maxSteps` removed |
Silently removed in AI SDK v6 — passing it does nothing. Use |
MCP transport type |
|
NAMS search is lexical |
Hosted NAMS matches keywords, not meaning — search with the user’s own words, not a paraphrase |
NAMS scoping |
Long-term entities belong to the workspace, not the user. Isolating users needs one NAMS workspace per user or tenant |
Edge runtime |
Neo4j driver needs persistent TCP — incompatible with Vercel edge functions; use Node.js serverless runtime |
NAMS `enforceQueryMemory` |
Only guards the read side — save the turn yourself in |