Docs
CodeRabbit
Cloudflare
AG Grid
SerpAPI
Netlify
OpenRouter
Neon
WorkOS
Clerk
Electric
PowerSync
Sentry
Railway
Prisma
Strapi
Unkey
CodeRabbit
Cloudflare
AG Grid
SerpAPI
Netlify
OpenRouter
Neon
WorkOS
Clerk
Electric
PowerSync
Sentry
Railway
Prisma
Strapi
Unkey
Class References
Function References
Interface References
Type Alias References
Variable References

Custom Adapter

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 contract

ts
// 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'
ts
// 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:

  1. recall decides relevance. Return a rendered systemPrompt (empty string when there's nothing), plus optional fragments, tools, and toolGuidance. Ranking strategy is entirely yours: lexical, vector, hybrid, or vendor-native.
  2. save owns extraction. Turn the { user, assistant } turn into whatever you persist. Return one SaveReceipt per underlying write.

Scope isolation is your responsibility: a recall for one scope must never surface another scope's data.

Step 1: Scaffold

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

Step 2: Run the contract suite

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

ts
// 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 })
})

Step 3: Wire it into memoryMiddleware

Once the suite is green, the adapter is interchangeable with the built-ins:

ts
// 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.

Exposing tools (optional)

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.

Pitfalls

  • Keep scopes isolated. If you serialize scope into a composite key, escape your delimiter so a sessionId/userId containing it can't collide with another scope.
  • recall must not throw for an empty scope. Return { systemPrompt: '' }.
  • Extraction lives in save. Don't expect the middleware to derive facts. The raw turn is handed to you; store or summarize it however you like.

Where to go next

  • Overview: the recall/save contract, scope, and how a turn flows
  • Adapters: the built-in and vendor adapters, with every option
  • Quickstart: wire memoryMiddleware into a real chat() call
  • Operating memory: options, telemetry, devtools events, and failures