Vercel AI SDK — NAMS Chat with Neo4j Agent Memory
A production-ready Next.js chat application demonstrating NAMS (Neo4j Agent Memory System) integrated with the Vercel AI SDK. Serves as both a working example and a reference client for the @neo4j-labs/nams-ai-provider package — persistent memory backed by Neo4j with zero infrastructure to manage.
Key features:
-
Three memory integration modes — provider middleware, model-instance middleware, or explicit model-driven tools
-
Live database access — optional Neo4j MCP server integration for real-time graph queries, available in every mode
-
Persistent cross-session memory — facts, preferences, and interaction history recalled across page reloads
-
Reasoning trace — per-step tool-call record stored in NAMS and surfaced in the UI
-
Portable — all NAMS memory logic lives in
@neo4j-labs/nams-ai-provider(installed from npm); drop it into any Vercel AI SDK project
Quick Start
git clone https://github.com/neo4j-labs/neo4j-agent-integrations.git
cd neo4j-agent-integrations/vercel-agent/vercel_Nams_demo
npm install # no flags needed — strict peer resolution passes
cp .env.local.example .env.local # set MEMORY_API_KEY and OPENAI_API_KEY at minimum
npm run dev # http://localhost:3000
@neo4j-labs/nams-ai-provider is a normal npm dependency (^0.2.1 in package.json) — nothing to build or link locally. Its peer dependencies target Vercel AI SDK v7 (ai@^7.0.0, @ai-sdk/mcp@^2.0.0, @ai-sdk/provider@^4.0.0), and this demo pins matching ai/@ai-sdk/* versions — the same set the notebook/ scripts use, so both samples resolve to identical dependency versions.
Plain npm install works out of the box — no --legacy-peer-deps, no .npmrc. The demo runs React 19 on Next.js 16, which satisfies every peer range in the tree, including @neo4j-ndl/react’s `react >=19.0.0.
It previously pinned React 18 on Next.js 14 and suppressed the resulting NDL peer conflict with a legacy-peer-deps=true .npmrc. React 19 removes the conflict at its source, so the escape hatch is gone. Note that bumping React alone would not have been enough: next@14 peers react@^18.2.0, so React 19 there just trades the NDL conflict for a Next one.
Integration Modes
All three modes talk to the same NAMS backend through the same package. Pick based on where you want memory to live in your control flow.
NAMS_MODE=provider # (default) transparent memory, wraps a provider
NAMS_MODE=middleware # transparent memory, wraps a model instance
NAMS_MODE=tools # model calls query_memory / store_memory explicitly
| Mode | Call | Memory handling | Tool calls visible in UI |
|---|---|---|---|
provider |
|
|
No |
middleware |
|
Same middleware, applied to an already-resolved model |
No |
tools |
|
|
Yes |
Choose provider when you construct models from a provider and want a drop-in replacement. Choose middleware when the base model is already resolved (e.g. it isn’t always openai). Choose tools when you want the memory cycle to be explicit and inspectable.
Mode 1 — Provider (transparent)
createNamsProvider() returns a ProviderV4-compatible provider. Every languageModel(id) call resolves through the base provider and wraps the result with a LanguageModelV4Middleware: memories are retrieved and injected into the prompt before the call, and the turn is persisted after it.
// app/api/chat/route.ts
import { createNamsProvider } from '@neo4j-labs/nams-ai-provider';
import { openai } from '@ai-sdk/openai';
const model = createNamsProvider({
apiKey: process.env.MEMORY_API_KEY!,
workspaceId: process.env.MEMORY_WORKSPACE_ID,
baseProvider: openai,
scope: { userId, conversationId },
}).languageModel('gpt-5.4-mini');
Options worth knowing: maxMemories (default 6) caps how many memories are injected per turn, persistInteractions (default true) toggles write-back, and extractionModel builds a real entity graph per stored turn at the cost of one extra model call.
Mode 2 — Middleware (transparent)
Identical memory behaviour, applied to a model instance instead of a provider:
import { createNams } from '@neo4j-labs/nams-ai-provider';
import { openai } from '@ai-sdk/openai';
const nams = createNams({ apiKey: process.env.MEMORY_API_KEY! });
const model = nams.wrap(openai('gpt-5.4-mini'), { userId, conversationId });
Mode 3 — Tools (model-driven)
createNams().toolsWithMcp() returns { query_memory, store_memory } merged with any Neo4j MCP tools in a single object, plus one close() covering both connections. The model decides when to call each tool; SYSTEM_PROMPT enforces the cycle.
import { createNams, enforceQueryMemory } from '@neo4j-labs/nams-ai-provider';
import { getNamsMcpConfig } from '@/lib/neo4j-mcp';
import { SYSTEM_PROMPT } from '@/lib/constants';
import { openai } from '@ai-sdk/openai';
import { ToolLoopAgent, stepCountIs } from 'ai';
const { tools, close } = await createNams({ apiKey })
.toolsWithMcp({ userId, conversationId }, getNamsMcpConfig());
const agent = new ToolLoopAgent({
model: openai('gpt-5.4-mini'),
instructions: SYSTEM_PROMPT,
tools,
prepareStep: enforceQueryMemory({ graceSteps: 2 }),
stopWhen: stepCountIs(10),
onFinish: async () => { await close(); },
});
enforceQueryMemory({ graceSteps: 2 }) is a prepareStep guard: if the model hasn’t called query_memory within the first two steps, the loop forces it. Without it, smaller models regularly answer from conversation history alone and skip memory entirely. It is applied in tools mode only — the other two modes have no memory tools to enforce.
Calling toolsWithMcp(scope) with no second argument returns NAMS memory tools only, and close() is a no-op.
Write-side counterpart: enforceQueryMemory can guarantee the read because there is always a later step to force. The write has no such hook — the loop ends the moment the model emits final text, so a turn where it says "I’ll remember that" without calling store_memory persists nothing. ensureStored() closes that gap from onFinish: it inspects the finished turn and stores it if the model didn’t. Failures are swallowed, since memory must never break a response.
MCP in provider / middleware mode: toolsWithMcp() also emits query_memory/store_memory, which would double-handle memory alongside the middleware. So those modes call getNeo4jMcpTools() directly and pass only the MCP tools to the agent.
Architecture
┌───────────────────────────────────────────────────────────────────┐
│ Browser (Next.js / React) │
│ │
│ ChatComponent (useChat + DefaultChatTransport) │
│ • sessionId from localStorage (key: nams-session-id) │
│ • POST /api/chat { messages, sessionId, conversationId? } │
│ • GET /api/reasoning?userId=&conversationId= │
│ │
│ ┌──────────────────────┐ ┌──────────────────────────────────┐ │
│ │ Memory Panel │ │ Reasoning Trace Panel │ │
│ │ recent / observ. / │ │ step 1 → step 2 → … → step N │ │
│ │ reasoning tabs │ │ reasoning / action / result │ │
│ └──────────────────────┘ └──────────────────────────────────┘ │
└───────────────────────┬───────────────────────────────────────────┘
│ HTTP streaming (UI message stream)
▼
┌────────────────────────────────────────────────────────────────────┐
│ Next.js API Routes (Node.js runtime) │
│ │
│ POST /api/chat GET /api/reasoning │
│ ─────────────────────────────── ───────────────────────── │
│ 1. Parse UIMessages + sessionId 1. findExistingConversation │
│ 2. Resolve model by NAMS_MODE 2. client.reasoning.listSteps │
│ 3. Connect MCP (mode-dependent) 3. Return step array as JSON │
│ 4. Build DATABASE ACCESS prompt (no conversation → []) │
│ 5. ToolLoopAgent streams response │
│ 6. onFinish → recordStep → close() │
│ │
└───────────────────────┬────────────────────────────────────────────┘
│ HTTPS REST
▼
┌────────────────────────────────────────────────────────────────────┐
│ NAMS — https://memory.neo4jlabs.com │
│ (@neo4j-labs/agent-memory SDK, backed by Neo4j AuraDB) │
│ │
│ ┌──────────────────┐ ┌───────────────────┐ ┌────────────────┐ │
│ │ Short-Term │ │ Long-Term │ │ Reasoning │ │
│ │ (conversation) │ │ (graph entities) │ │ (step records)│ │
│ │ • search │ │ • facts │ │ • reasoning │ │
│ │ • per convId │ │ • user_pref │ │ • actionTaken │ │
│ │ • cross-session │ │ • patterns │ │ • result │ │
│ └──────────────────┘ └───────────────────┘ └────────────────┘ │
└────────────────────────────────────────────────────────────────────┘
Memory flow per turn (tools mode)
User sends message
│
▼
[tool] query_memory ──────────────────────────────────────────────┐
│ searches: │
│ • current conversation (short-term) │
│ • past conversations (cross-session, same userId) │
│ • long-term graph (entities, facts, preferences) │
│ • prior reasoning steps │
│◄── returns ranked MemoryHit[] ─────────────────────────────┘
│ (forced by enforceQueryMemory if skipped for 2 steps)
│
[tool] MCP database tools (if configured)
│ schema lookup → Cypher read → results
│
▼
LLM composes personalised answer
│
▼
[tool] store_memory
│ routes by type:
│ interaction → short-term conversation thread
│ fact → long-term graph entity
│ user_preference → long-term graph entity
│ pattern → long-term graph entity
▼
onFinish → recordStep (one per agent step) → close()
│
▼
Response streams to browser
In provider and middleware modes the two [tool] memory steps disappear — retrieval and persistence happen inside the model wrapper — and the agent runs a single step unless MCP tools are attached.
Project Structure
vercel_Nams_demo/
│
├── app/
│ ├── api/
│ │ ├── chat/route.ts ← POST /api/chat — mode switching, ToolLoopAgent
│ │ └── reasoning/route.ts ← GET /api/reasoning — fetch stored reasoning steps
│ ├── globals.css
│ ├── layout.tsx
│ └── page.tsx ← dark/light shell + AppHeader + ChatComponent
│
├── components/
│ ├── AppHeader.tsx
│ └── chat/
│ ├── ChatComponent.tsx ← useChat, streaming, session id, reasoning fetch
│ ├── MemoryPanel.tsx ← retrieved memories (recent / observations / reasoning)
│ ├── ReasoningPanel.tsx ← per-step reasoning trace
│ └── styles.ts
│
├── lib/
│ ├── constants.ts ← SYSTEM_PROMPT + buildDbToolsPrompt()
│ ├── nams-enrich.ts ← ensureStored() — persists a turn the model didn't store
│ └── neo4j-mcp.ts ← MCP client, auth resolution, explainMcpError()
│
├── test/
│ ├── chat-route.test.ts ← mode wiring + error paths for /api/chat
│ └── reasoning-route.test.ts ← trace lookup + error paths for /api/reasoning
│
├── types/index.ts ← MemoryHit, QueryOutput, ReasoningStep, ParsedMemory
├── utils/message.ts ← getMsgText, parseMemory, formatErrorMessage
├── constants.ts ← DEFAULT_SUGGESTIONS, SESSION_STORAGE_KEY
├── declarations.d.ts ← `declare module '*.css'`
├── .env.local.example
├── package.json
├── vitest.config.mts
└── next.config.js
NAMS integration lives entirely in `@neo4j-labs/nams-ai-provider` — a published npm package, not vendored source. The demo only wires it up.
Setup
1. Install
npm install
No flags required — the dependency tree resolves cleanly under npm’s strict peer checking.
Environment Variables
| Variable | Required | Default | Description |
|---|---|---|---|
|
Yes |
— |
API key from memory.neo4jlabs.com. Missing → every |
|
Yes |
— |
OpenAI API key, read by |
|
No |
|
|
|
No |
(key default) |
Pin to a specific NAMS workspace |
|
No |
|
LLM model ID |
|
No |
(off) |
When set, builds a real entity graph per stored memory (one extra model call). Applies in all three modes |
|
No |
— |
Neo4j MCP server URL (enables live graph access) |
|
No |
— |
Used as |
|
No |
— |
|
|
No |
— |
Basic auth username |
|
No |
— |
Basic auth password |
|
No |
— |
Comma-separated extra hosts allowed to load |
MCP stays disabled unless MCP_URL (or MCP_PORT) is set and one auth pair is supplied: either MCP_BEARER_TOKEN, or both MCP_NEO4J_USERNAME and MCP_NEO4J_PASSWORD.
Not sure which your server wants? curl -i -X POST $MCP_URL and read the WWW-Authenticate header: Bearer … means a token, Basic … means user/password.
Testing the Integration Paths
The demo is the reference client for the provider, so each supported wiring has a manual path worth walking after an upgrade.
Path A — tools mode, no MCP (NAMS_MODE=tools, MCP vars unset)
Exercises createNams().toolsWithMcp(scope) — memory tools only.
Send: "My name is Alex and I like TypeScript"
→ Memory + Reasoning panels appear above the answer
→ Reasoning trace: query_memory → answer → store_memory
→ Send: "What language do I like?"
→ query_memory returns found=true; the model answers from memory
Path B — tools mode + MCP (add MCP_URL plus MCP_BEARER_TOKEN or MCP_NEO4J_USERNAME/MCP_NEO4J_PASSWORD)
Exercises createNams().toolsWithMcp(scope, mcpConfig) — memory + database tools merged.
Send: "What nodes are in my Neo4j database?"
→ [chat] model=gpt-5.4-mini maxSteps=10 tools=5 db=[get_neo4j_schema, read_neo4j_cypher, write_neo4j_cypher]
→ Agent calls the schema tool, then the read tool, then offers to store findings
Exact tool names vary by server. buildDbToolsPrompt() builds the DATABASE ACCESS block from whatever the server reported at connect time, so the prompt never advertises a tool the model can’t call.
Path C — provider mode (NAMS_MODE=provider)
Exercises createNamsProvider() — transparent middleware, no visible tool calls.
Send: "My favourite colour is blue"
→ No query_memory / store_memory in the logs — memory is middleware-driven
→ Refresh the page (same localStorage session id); send: "What's my favourite colour?"
→ Model answers "blue" — retrieved before the model call
Path D — middleware mode (NAMS_MODE=middleware)
Same observable behaviour as Path C, via createNams().wrap() instead of a provider.
Inspect reasoning steps directly
curl "http://localhost:3000/api/reasoning?userId=<your-session-id>"
The session id is in localStorage under nams-session-id.
Server log reference
Every request opens with a banner and closes with a summary:
| Log line | Meaning |
|---|---|
|
Mode resolved; whether MCP env vars are complete |
|
Scope and truncated user message for this turn |
|
MCP connected; the names it reported |
|
Tool count registered with the agent; |
|
Env vars set but the connection failed — database questions will fail |
`[chat] Done \ |
steps=N queries=N stores=N elapsed=Xms` |
|
Usage for the turn |
|
Agent produced no text; fallback message emitted to the UI |
|
Reasoning trace served to the panel |
|
Non-fatal warning from the provider package itself |
Automated tests
test/ unit-tests both API routes against a mocked @neo4j-labs/nams-ai-provider — no live NAMS or OpenAI credentials needed.
npm test # single run — 18 tests
npm run test:watch
| File | Covers |
|---|---|
|
provider mode wraps via |
|
|
Run these after any provider upgrade to confirm the demo still speaks the package’s current API before clicking through the UI.
Using NAMS in Your Own Project
npm install @neo4j-labs/nams-ai-provider @neo4j-labs/agent-memory ai zod
import { createNamsProvider, createNams } from '@neo4j-labs/nams-ai-provider';
import { openai } from '@ai-sdk/openai';
// Provider mode — transparent
const model = createNamsProvider({ apiKey, baseProvider: openai, scope: { userId } })
.languageModel('gpt-5.4-mini');
// Middleware mode — transparent, wraps an existing model
const wrapped = createNams({ apiKey }).wrap(openai('gpt-5.4-mini'), { userId });
// Tools mode — model-driven, tool calls visible in the UI
const { tools, close } = await createNams({ apiKey }).toolsWithMcp({ userId });
Troubleshooting
Memory not persisting across sessions
-
Check that the session id is stable — the app reads
localStorage.getItem('nams-session-id')and sends it assessionId; clearing site data starts a new user -
Verify
MEMORY_API_KEYis set and restart the dev server after editing.env.local -
In tools mode, confirm the reasoning trace shows a
store_memorystep
Model never calls memory tools (tools mode)
-
Confirm
NAMS_MODE=toolsand that the server restarted -
enforceQueryMemoryforcesquery_memoryafter 2 steps, so a total absence usually means the tools weren’t attached — check[chat] … tools=Nshows N ≥ 2 -
Try a larger model, e.g.
OPENAI_MODEL=gpt-5.4— smaller models are less reliable with multi-tool cycles
MCP connection fails
-
Verify a URL and one complete auth pair are set (see the env table)
-
HTTP 401 is usually the wrong auth scheme, not wrong credentials.
explainMcpError()re-probes the endpoint on a 401 and logs the server’sWWW-Authenticatechallenge, e.g.server requires bearer auth, but the MCP_* env vars produced basic. Hosted Aura / NeoCompanion endpoints are OAuth 2.1 — mint a token and setMCP_BEARER_TOKEN -
Tools mode falls back to NAMS-only tools when MCP is unavailable; provider/middleware modes continue with no tools at all
Model answers from memory instead of querying the database
-
Check for
db=[…]in the[chat] model=…line. Nodb=[…]means no database tools were attached, so the model cannot query — fix the connection first -
The DATABASE ACCESS prompt block is generated from the tool names the server reports at connect time, so it always matches what the model can actually call
"I ran out of steps…" in the chat
-
The tool loop hit
maxSteps=10without producing text. Usually a model looping onquery_memorywith reworded keywords — try a larger model, or check whether the question actually needs database tools that aren’t connected
HTTP 503 / `MEMORY_API_KEY is not set`
-
Generate a free key at memory.neo4jlabs.com and restart
npm run dev
`400 string_above_max_length` from OpenAI, or a vague "I was not able to produce an answer"
-
A
read-cyphercall without aLIMITclause can return a very large result set, exceeding OpenAI’s per-request size limit and failing the whole turn.lib/neo4j-mcp.ts’s `capToolOutputs()truncates any tool result over 50,000 characters and nudges the model to add aLIMITclause — if you still hit this, ask a narrower question or addLIMIT 25yourself
Known limitation (upstream package)
During end-to-end testing with two different userId`s sharing one NAMS workspace, a brand-new user’s very first turn occasionally surfaced a fact stored under a different user minutes earlier. `route.ts passes a correctly-scoped { userId, conversationId } to both createNamsProvider(…) and createNams(…).wrap(…), so this behavior appears to originate inside @neo4j-labs/nams-ai-provider/@neo4j-labs/agent-memory’s conversation/entity retrieval rather than in this demo’s code. If you see unexpected cross-user recall, pass an explicit `conversationId per user/session and report the observation upstream at neo4j-labs/agent-memory.
Dependencies
| Package | Role |
|---|---|
|
NAMS integration — |
|
NAMS REST client used by the provider |
|
|
|
OpenAI model provider |
|
|
|
MCP client used by |
|
Neo4j Design Language UI components |
|
Tool input schemas |
|
App framework and UI runtime |
|
Route-level tests ( |