You have a backend in mind (pgvector, MongoDB, DynamoDB, a hosted memory API) and the built-in inMemory() / redis() adapters don't fit. A memory adapter is just an object with two methods, recall and save, so this is a short guide.
First time looking at memory? Start with the Overview for what the contract is and how the middleware uses it.
// The MemoryAdapter contract, from `@tanstack/ai-memory`:
import type { MemoryAdapter } from '@tanstack/ai-memory'// The MemoryAdapter contract, from `@tanstack/ai-memory`:
import type { MemoryAdapter } from '@tanstack/ai-memory'// The shape of the contract, shown for reference.
import type {
MemoryFact,
MemoryScope,
MemorySnapshot,
MemoryTurn,
RecallResult,
SaveReceipt,
} from '@tanstack/ai-memory'
interface MemoryAdapter {
id: string
recall(scope: MemoryScope, query: string): Promise<RecallResult>
save(scope: MemoryScope, turn: MemoryTurn): Promise<Array<SaveReceipt>>
inspect?(scope: MemoryScope): Promise<MemorySnapshot> // optional (devtools)
listFacts?(scope: MemoryScope): Promise<Array<MemoryFact>> // optional (devtools)
}// The shape of the contract, shown for reference.
import type {
MemoryFact,
MemoryScope,
MemorySnapshot,
MemoryTurn,
RecallResult,
SaveReceipt,
} from '@tanstack/ai-memory'
interface MemoryAdapter {
id: string
recall(scope: MemoryScope, query: string): Promise<RecallResult>
save(scope: MemoryScope, turn: MemoryTurn): Promise<Array<SaveReceipt>>
inspect?(scope: MemoryScope): Promise<MemorySnapshot> // optional (devtools)
listFacts?(scope: MemoryScope): Promise<Array<MemoryFact>> // optional (devtools)
}Two rules the middleware relies on:
Scope isolation is your responsibility: a recall for one scope must never surface another scope's data.
import type {
MemoryAdapter,
MemoryScope,
MemoryTurn,
RecallResult,
SaveReceipt,
} from '@tanstack/ai-memory'
// node-postgres' Pool, minimally. In your project, use the real type instead:
// import type { Pool } from 'pg'
type Pool = {
query: (
text: string,
values: Array<unknown>,
) => Promise<{ rows: Array<{ text: string }> }>
}
// Your embedding client. Swap in OpenAI, Cohere, a local model, etc.
type Embed = (text: string) => Promise<Array<number>>
export function pgvectorMemory(options: { pool: Pool; embed: Embed }): MemoryAdapter {
const { pool, embed } = options
return {
id: 'pgvector',
async save(scope: MemoryScope, turn: MemoryTurn): Promise<Array<SaveReceipt>> {
const rows = [
{ role: 'user', text: turn.user },
{ role: 'assistant', text: turn.assistant },
]
for (const row of rows) {
const vector = await embed(row.text)
await pool.query(
`INSERT INTO memory (session_id, user_id, role, text, embedding)
VALUES ($1, $2, $3, $4, $5)`,
[scope.sessionId, scope.userId ?? null, row.role, row.text, JSON.stringify(vector)],
)
}
return [{ ok: true }]
},
async recall(scope: MemoryScope, query: string): Promise<RecallResult> {
const q = await embed(query)
const { rows } = await pool.query(
`SELECT text, 1 - (embedding <=> $1::vector) AS score
FROM memory
WHERE session_id = $2 AND ($3::text IS NULL OR user_id = $3)
ORDER BY score DESC
LIMIT 6`,
[JSON.stringify(q), scope.sessionId, scope.userId ?? null],
)
const fragments = rows.map((r) => ({ text: r.text, source: 'pgvector' }))
const systemPrompt = fragments.length
? `Relevant memory:\n${fragments.map((f) => `- ${f.text}`).join('\n')}`
: ''
return { systemPrompt, fragments }
},
}
}import type {
MemoryAdapter,
MemoryScope,
MemoryTurn,
RecallResult,
SaveReceipt,
} from '@tanstack/ai-memory'
// node-postgres' Pool, minimally. In your project, use the real type instead:
// import type { Pool } from 'pg'
type Pool = {
query: (
text: string,
values: Array<unknown>,
) => Promise<{ rows: Array<{ text: string }> }>
}
// Your embedding client. Swap in OpenAI, Cohere, a local model, etc.
type Embed = (text: string) => Promise<Array<number>>
export function pgvectorMemory(options: { pool: Pool; embed: Embed }): MemoryAdapter {
const { pool, embed } = options
return {
id: 'pgvector',
async save(scope: MemoryScope, turn: MemoryTurn): Promise<Array<SaveReceipt>> {
const rows = [
{ role: 'user', text: turn.user },
{ role: 'assistant', text: turn.assistant },
]
for (const row of rows) {
const vector = await embed(row.text)
await pool.query(
`INSERT INTO memory (session_id, user_id, role, text, embedding)
VALUES ($1, $2, $3, $4, $5)`,
[scope.sessionId, scope.userId ?? null, row.role, row.text, JSON.stringify(vector)],
)
}
return [{ ok: true }]
},
async recall(scope: MemoryScope, query: string): Promise<RecallResult> {
const q = await embed(query)
const { rows } = await pool.query(
`SELECT text, 1 - (embedding <=> $1::vector) AS score
FROM memory
WHERE session_id = $2 AND ($3::text IS NULL OR user_id = $3)
ORDER BY score DESC
LIMIT 6`,
[JSON.stringify(q), scope.sessionId, scope.userId ?? null],
)
const fragments = rows.map((r) => ({ text: r.text, source: 'pgvector' }))
const systemPrompt = fragments.length
? `Relevant memory:\n${fragments.map((f) => `- ${f.text}`).join('\n')}`
: ''
return { systemPrompt, fragments }
},
}
}The shape generalizes: every method takes a scope, does its backend-specific work, and keeps scopes isolated. For a backend without native search, load the scope's records and rank them yourself.
@tanstack/ai-memory/tests/contract exports runMemoryAdapterContract. Point it at a factory that returns a fresh adapter. It verifies the save then recall round-trip, scope isolation, empty recall, receipt shape, and the optional introspection methods.
// ignore: imports the `../src/pgvector` module you wrote in Step 1.
// tests/pgvector.test.ts
import { runMemoryAdapterContract } from '@tanstack/ai-memory/tests/contract'
import { pgvectorMemory } from '../src/pgvector'
runMemoryAdapterContract('pgvectorMemory', async () => {
const pool = makeCleanPool() // truncate between tests for a fresh adapter
return pgvectorMemory({ pool, embed })
})// ignore: imports the `../src/pgvector` module you wrote in Step 1.
// tests/pgvector.test.ts
import { runMemoryAdapterContract } from '@tanstack/ai-memory/tests/contract'
import { pgvectorMemory } from '../src/pgvector'
runMemoryAdapterContract('pgvectorMemory', async () => {
const pool = makeCleanPool() // truncate between tests for a fresh adapter
return pgvectorMemory({ pool, embed })
})Once the suite is green, the adapter is interchangeable with the built-ins:
// ignore: imports the `./pgvector` module you wrote in Step 1, and assumes
// `messages` / `scope` from your app.
import { chat } from '@tanstack/ai'
import { openaiText } from '@tanstack/ai-openai'
import { memoryMiddleware } from '@tanstack/ai-memory'
import { pgvectorMemory } from './pgvector'
const memory = pgvectorMemory({ pool, embed })
const stream = chat({
adapter: openaiText('gpt-5.5'),
messages,
middleware: [memoryMiddleware({ adapter: memory, scope })],
})// ignore: imports the `./pgvector` module you wrote in Step 1, and assumes
// `messages` / `scope` from your app.
import { chat } from '@tanstack/ai'
import { openaiText } from '@tanstack/ai-openai'
import { memoryMiddleware } from '@tanstack/ai-memory'
import { pgvectorMemory } from './pgvector'
const memory = pgvectorMemory({ pool, embed })
const stream = chat({
adapter: openaiText('gpt-5.5'),
messages,
middleware: [memoryMiddleware({ adapter: memory, scope })],
})The middleware never inspects the adapter's internals. recall/save is the entire interface.
recall can return tools and toolGuidance to give the model direct control over memory (this is how the hindsight() adapter exposes retain/recall/reflect tools). The middleware merges them into the run's tools and injects the guidance ahead of the recalled prompt. Return tools: [] (or omit it) when your adapter exposes none.