ai-ml

Give an AI Agent Long-Term Memory with pgvector (2026)

July 17, 2026

Give an AI Agent Long-Term Memory with pgvector (2026)

An AI agent's context window resets every session — it has no idea who you are the second time you talk to it. This tutorial builds a small TypeScript agent that fixes that with Postgres and the pgvector extension: Claude decides what's worth remembering, a remember_fact tool stores it as an embedding, and a cosine-similarity query recalls it on every later turn — including after the whole process has been killed and restarted1.

TL;DR

You'll build a CLI agent that pairs Claude (claude-sonnet-5) with a Postgres 18 + pgvector memory store2. A remember_fact tool lets Claude decide when something about the user is worth persisting; before every reply, the app embeds the user's message with OpenAI's text-embedding-3-small3 and pulls the closest matching memories with pgvector's <=> cosine-distance operator4, injecting them into the system prompt. Stop the process, start it again, and the agent still knows your name. Runtime: Node.js 24 LTS5, @anthropic-ai/sdk@0.112.1, pg@8.22.0. Build time: 25-30 minutes.

What you'll learn

  • How to run Postgres with the pgvector extension pre-installed using the official pgvector/pgvector Docker image
  • How to store embeddings in a vector column and query them with pgvector's <=> cosine-distance operator
  • How to give Claude a remember_fact tool so the model decides what's worth persisting, instead of embedding every message blindly
  • How to retrieve relevant memories before each turn and inject them into the system prompt
  • How to prove memory survived a restart by killing the Node process and starting a fresh one
  • What to do when recall returns nothing, or returns the wrong memories

Prerequisites

  • Docker (any recent version) — used to run pgvector/pgvector:pg18, the official Postgres 18 image with pgvector pre-installed6
  • Node.js 24 LTS or later — released 2025-05-06, in Active LTS since 2025-10-28 (moves to Maintenance LTS on 2026-10-20), supported through 2028-04-305
  • An ANTHROPIC_API_KEY (Claude) and an OPENAI_API_KEY (for embeddings only — Anthropic doesn't offer an embeddings endpoint, so text-embedding-3-small fills that one gap)7
  • Comfort with basic SQL; you won't write any migrations tooling, just raw CREATE TABLE and parameterized queries

Step 1: Start Postgres with pgvector

Skip compiling the extension yourself — the pgvector project publishes a Docker image that's the standard Postgres image with pgvector already built in6:

docker run -d \
  --name agent-memory-db \
  -e POSTGRES_PASSWORD=postgres \
  -e POSTGRES_DB=agent_memory \
  -p 5432:5432 \
  pgvector/pgvector:pg18

pg18 resolves to Postgres 18 (currently at patch release 18.4) with pgvector's latest patch pre-installed — the tag tracks pgvector's release cadence automatically, so you'll get whichever patch is current when you pull it, not necessarily whatever was current when this was written. The 0.8.2 release (2026-02-25) fixed a buffer-overflow bug in parallel HNSW index builds28; after Step 2, SELECT extversion FROM pg_extension WHERE extname = 'vector'; tells you exactly which patch you got. Confirm the container is up:

docker exec agent-memory-db pg_isready -U postgres
# /var/run/postgresql:5432 - accepting connections

Step 2: Create the memory table

Enable the extension and create one table: a resource ID (who this memory belongs to), the fact as text, and its embedding.

-- schema.sql
CREATE EXTENSION IF NOT EXISTS vector;

CREATE TABLE IF NOT EXISTS agent_memories (
  id bigserial PRIMARY KEY,
  resource_id text NOT NULL,
  content text NOT NULL,
  embedding vector(1536) NOT NULL,
  created_at timestamptz NOT NULL DEFAULT now()
);

CREATE INDEX IF NOT EXISTS agent_memories_embedding_idx
  ON agent_memories USING hnsw (embedding vector_cosine_ops);

vector(1536) matches text-embedding-3-small's default output size3. The hnsw index with vector_cosine_ops is what makes <=> fast once you have more than a few hundred rows — pgvector does exact search by default and only turns approximate once an index like this exists4. Apply it:

docker exec -i agent-memory-db psql -U postgres -d agent_memory < schema.sql

Step 3: Set up the TypeScript project

mkdir agent-memory && cd agent-memory
npm init -y
npm install @anthropic-ai/sdk@0.112.1 openai@6.48.0 pg@8.22.0 pgvector@0.3.0
npm install -D typescript@6.0.3 @types/node@24.13.3 @types/pg@8.20.0 tsx@4.23.1

pgvector here is the npm package (unrelated in version number to the Postgres extension) that converts JS number arrays to the Postgres vector literal format and registers the type on a pg client9. Add "type": "module" to package.json, then create tsconfig.json:

{
  "compilerOptions": {
    "target": "ES2022",
    "module": "NodeNext",
    "moduleResolution": "NodeNext",
    "esModuleInterop": true,
    "forceConsistentCasingInFileNames": true,
    "strict": true,
    "skipLibCheck": true,
    "noEmit": true,
    "allowImportingTsExtensions": true
  },
  "include": ["*.ts"]
}

allowImportingTsExtensions matters: the files below import each other as ./db.ts, not ./db.js, which only type-checks with this flag on (and requires noEmit: true, which you want anyway since tsx handles execution).

Step 4: Connect to Postgres and write the memory functions

db.ts opens a connection pool and registers pgvector's types on every new connection:

// db.ts
import pg from "pg";
import pgvector from "pgvector/pg";

const { Pool } = pg;

export const pool = new Pool({
  connectionString: process.env.DATABASE_URL ?? "postgres://postgres:postgres@localhost:5432/agent_memory",
  onConnect: async (client) => {
    await pgvector.registerTypes(client);
  },
});

memory.ts does the actual work: embed text with OpenAI, store it, and recall the closest matches for a given user.

// memory.ts
import OpenAI from "openai";
import pgvector from "pgvector/pg";
import { pool } from "./db.ts";

const openai = new OpenAI();

export async function embed(text: string): Promise<number[]> {
  const response = await openai.embeddings.create({
    model: "text-embedding-3-small",
    input: text,
  });
  const vector = response.data[0]?.embedding;
  if (!vector) throw new Error("OpenAI returned no embedding");
  return vector;
}

export async function rememberFact(resourceId: string, fact: string): Promise<void> {
  const embedding = await embed(fact);
  await pool.query(
    "INSERT INTO agent_memories (resource_id, content, embedding) VALUES ($1, $2, $3)",
    [resourceId, fact, pgvector.toSql(embedding)],
  );
}

export interface RecalledMemory {
  content: string;
  distance: number;
}

export async function recallMemories(
  resourceId: string,
  query: string,
  limit = 3,
): Promise<RecalledMemory[]> {
  const embedding = await embed(query);
  const result = await pool.query<{ content: string; distance: number }>(
    `SELECT content, embedding <=> $2 AS distance
       FROM agent_memories
      WHERE resource_id = $1
      ORDER BY embedding <=> $2
      LIMIT $3`,
    [resourceId, pgvector.toSql(embedding), limit],
  );
  return result.rows;
}

pgvector.toSql() turns a JS array into the string Postgres expects for a vector literal — [1, 2, 3] becomes "[1,2,3]", matching the raw-SQL examples in pgvector's own docs9. <=> is cosine distance, so smaller is more similar; ORDER BY ... ASC already puts the closest matches first, no conversion to similarity needed4.

Step 5: Let Claude decide what to remember

This is the part that separates a real memory feature from indiscriminately embedding every message: give Claude a tool, and let the model call it only when something is actually worth keeping.

// agent.ts
import Anthropic from "@anthropic-ai/sdk";
import type { MessageParam, Tool, ToolUseBlock } from "@anthropic-ai/sdk/resources/messages";
import { rememberFact, recallMemories } from "./memory.ts";

const anthropic = new Anthropic();

const rememberFactTool: Tool = {
  name: "remember_fact",
  description:
    "Save a durable fact about the user for future conversations — their name, plan, " +
    "preferences, or anything else worth recalling later. Call this whenever the user " +
    "shares something that should persist beyond this one exchange.",
  input_schema: {
    type: "object",
    properties: {
      fact: {
        type: "string",
        description: "A single, self-contained fact written in third person, e.g. \"The user's name is Sam.\"",
      },
    },
    required: ["fact"],
  },
};

export async function runTurn(resourceId: string, userMessage: string): Promise<string> {
  const recalled = await recallMemories(resourceId, userMessage);
  const memoryBlock = recalled.length
    ? `Relevant memories about this user:\n${recalled.map((m) => `- ${m.content}`).join("\n")}`
    : "No stored memories about this user yet.";

  const messages: MessageParam[] = [{ role: "user", content: userMessage }];

  let response = await anthropic.messages.create({
    model: "claude-sonnet-5",
    max_tokens: 1024,
    system: `You are a helpful assistant with long-term memory.\n\n${memoryBlock}`,
    tools: [rememberFactTool],
    tool_choice: { type: "auto", disable_parallel_tool_use: true },
    messages,
  });

  while (response.stop_reason === "tool_use") {
    const toolUse = response.content.find((b): b is ToolUseBlock => b.type === "tool_use");
    if (!toolUse) break;

    let resultText = "ok";
    if (toolUse.name === "remember_fact") {
      const { fact } = toolUse.input as { fact: string };
      await rememberFact(resourceId, fact);
      resultText = `stored: "${fact}"`;
    }

    messages.push({ role: "assistant", content: response.content });
    messages.push({
      role: "user",
      content: [{ type: "tool_result", tool_use_id: toolUse.id, content: resultText }],
    });

    response = await anthropic.messages.create({
      model: "claude-sonnet-5",
      max_tokens: 1024,
      system: `You are a helpful assistant with long-term memory.\n\n${memoryBlock}`,
      tools: [rememberFactTool],
      tool_choice: { type: "auto", disable_parallel_tool_use: true },
      messages,
    });
  }

  const finalText = response.content.find((b) => b.type === "text");
  return finalText && finalText.type === "text" ? finalText.text : "";
}

The while (response.stop_reason === "tool_use") loop is the standard client-tool round trip: Claude returns a tool_use block instead of text, your code runs the tool and appends a tool_result block, and you call the API again until Claude replies with text10. Recall itself isn't a tool call — it runs deterministically before Claude sees the message, keeping the common case to a single API round trip.

tool_choice: { type: "auto", disable_parallel_tool_use: true } matters more than it looks: by default Claude can return multiple tool_use blocks in one response, and this loop only reads the first one with .find().

Without disabling parallel calls, a message like "My name is Sam and I'm on the Pro plan" — two independent facts — could make Claude fire remember_fact twice in a single turn; the second call would never get a matching tool_result, and the next API call would fail because every tool_use block requires one11. Capping it at one tool call per turn means Sam's two facts get stored across two loop iterations instead, which is exactly what the Verification section below shows.

Step 6: Wire up a CLI loop

// run.ts
import { createInterface } from "node:readline/promises";
import { stdin, stdout } from "node:process";
import { runTurn } from "./agent.ts";
import { pool } from "./db.ts";

const RESOURCE_ID = "demo-user";

async function main() {
  const rl = createInterface({ input: stdin, output: stdout });
  console.log("Agent memory demo. Type a message, or 'exit' to quit.\n");

  while (true) {
    const input = await rl.question("you> ");
    if (input.trim().toLowerCase() === "exit") break;
    const reply = await runTurn(RESOURCE_ID, input);
    console.log(`agent> ${reply}\n`);
  }

  rl.close();
  await pool.end();
}

main().catch((err) => {
  console.error(err);
  process.exit(1);
});

RESOURCE_ID is hardcoded to keep the demo focused on persistence, not auth — in a real app it would come from your session/user system, and every memory row and recall query would be scoped to it exactly like this.

Verification

What was actually run: all four files (db.ts, memory.ts, agent.ts, run.ts, 157 lines total) were installed against the real npm packages from Step 3 and type-checked with npx tsc --noEmit — zero errors, confirming the Tool, MessageParam, and ToolUseBlock types from @anthropic-ai/sdk@0.112.1 and the pg/pgvector APIs are used correctly. pgvector.toSql() was executed directly: toSql([1, 2, 3]) returns "[1,2,3]", matching the literal format pgvector's own SQL examples use9. The schema.sql syntax was cross-checked line-by-line against pgvector's official README4.

What was not run end-to-end: a live round trip against real Postgres and the real Claude/OpenAI APIs — this sandbox has neither Docker nor a paid API key. If you have both, here's what to expect. First run:

you> My name is Sam and I'm on the Pro plan.
agent> Nice to meet you, Sam! I've made a note that you're on the Pro plan —
let me know if you need help with anything.
you> exit

Kill the process (Ctrl+C or just close the terminal), then start it again — a fresh Node process, fresh in-memory state, same Postgres container:

you> What's my name, and what plan am I on?
agent> Your name is Sam, and you're on the Pro plan.
you> exit

Confirm it directly in the database, independent of the agent:

docker exec agent-memory-db psql -U postgres -d agent_memory \
  -c "SELECT resource_id, content, created_at FROM agent_memories ORDER BY created_at;"
 resource_id |            content             |          created_at
-------------+---------------------------------+-------------------------------
 demo-user   | The user's name is Sam.         | 2026-07-17 09:14:02.113+00
 demo-user   | The user is on the Pro plan.    | 2026-07-17 09:14:02.897+00

Two rows, two separate remember_fact calls — Claude split the message into two atomic facts rather than storing the whole sentence verbatim, which is typical behavior given the tool description asks for "a single, self-contained fact."

Troubleshooting

relation "agent_memories" does not exist. schema.sql was never applied, or was applied to a different database than the one in your connection string. Re-run the docker exec ... psql command from Step 2 and check POSTGRES_DB=agent_memory matches DATABASE_URL.

type "vector" does not exist. CREATE EXTENSION IF NOT EXISTS vector; didn't run — usually schema.sql reached the wrong database, or the container started from a stale volume that predates the extension. Connect with psql and run that line by hand to confirm.

Recall always returns an empty array. Check that resource_id matches exactly between the rememberFact call and the recallMemories call — a dynamically-generated ID (e.g., a new UUID per process instead of a stable one) means every query searches for a user with no rows yet. This is the most common bug in an otherwise-correct memory system.

Recall returns memories, but not the most relevant ones. With only a handful of rows this shouldn't happen — pgvector does exact search until the hnsw index actually kicks in, at which point results become approximate by design4. If recall quality matters more than speed at scale, raise hnsw.ef_search above its default of 40: SET hnsw.ef_search = 100;4.

Claude never calls remember_fact, even for facts you'd expect it to store. The tool's description field does the steering — Claude decides whether to call a tool based on the request and that description10. A system-prompt nudge like "Always call remember_fact when the user shares their name, preferences, or account details" pushes the model toward calling it more eagerly.

ECONNREFUSED connecting to Postgres. The container isn't up yet, or isn't listening on 5432. Run docker exec agent-memory-db pg_isready -U postgres from Step 1; if that fails, check docker logs agent-memory-db before touching the application code.

Why not just embed every message?

You could skip remember_fact and embed every message automatically, but that fills the table with noise — "what's the weather like" doesn't belong in a Pro-plan customer's permanent profile, and a raw message like "yeah let's go with that one" is meaningless out of context six turns later. Making Claude call a tool forces it to write memories as self-contained facts ("The user prefers the annual billing plan"), which is what actually makes recall useful later.

Next steps and further reading

This tutorial uses exact-match resource-scoped memory with a handful of rows, so pgvector's default exact search is plenty fast. Once a single user accumulates thousands of memories, revisit the hnsw index tuning in pgvector HNSW Postgres 18 Production Tuning Tutorial 2026ef_search, halfvec quantization, and parallel index builds all apply directly to an agent_memories table at scale.

If you're unsure why <=> cosine distance was the right operator to reach for instead of <#> inner product, Cosine Similarity vs Dot Product for Embeddings (2026) covers the math and when each one wins. And if you're building on the Claude Agent SDK rather than the raw Messages API, Anthropic ships a different, file-based persistence mechanism worth comparing against this one: Claude Memory Tool + Context Editing: Agent Tutorial (2026).

For a deeper dive into the retrieval side — combining full-text search with a vector query, then using Reciprocal Rank Fusion or a cross-encoder to merge the results — see pgvector's own notes on hybrid search, which apply just as well to agent memories as they do to document search.

Footnotes

  1. This tutorial's memory mechanism (external Postgres + pgvector, application-controlled) is unrelated to Anthropic's own file-based Memory tool feature for the Claude Agent SDK — see the Next Steps section for a link comparing the two.

  2. pgvector CHANGELOG — https://github.com/pgvector/pgvector/blob/master/CHANGELOG.md (0.8.2, released 2026-02-25, fixed a buffer-overflow bug in parallel HNSW index builds — this specific historical fact was confirmed identically across every fetch attempt. Repeated fetches of this changelog and of Docker Hub's tag registry disagreed on exactly which patch is current as of 2026-07-17, so this tutorial deliberately doesn't assert a specific "latest" patch number for pg18 — see Step 1 for how to check yours directly with SELECT extversion; fetched 2026-07-17) 2

  3. OpenAI, Vector embeddings guide — https://platform.openai.com/docs/guides/embeddings (text-embedding-3-small outputs 1,536 dimensions by default; reducible via the dimensions parameter, per the guide's own worked example shortening text-embedding-3-large to 256 dimensions using the same mechanism) and OpenAI's pricing page (Standard tier: $0.02 per 1M input tokens as of this writing); cross-referenced against third-party pricing trackers, fetched 2026-07-17 2

  4. pgvector README — https://github.com/pgvector/pgvector/blob/master/README.md (<=> cosine distance operator, exact-vs-approximate search behavior, HNSW index creation syntax, hnsw.ef_search default of 40; fetched 2026-07-17 directly from the master branch) 2 3 4 5 6

  5. Node.js release schedule — https://endoflife.date/nodejs and the Node.js Release Working Group's own schedule at https://github.com/nodejs/Release (Node 24: initial release 2025-05-06, Active LTS start 2025-10-28, Active LTS until 2026-10-20, security support until 2028-04-30, latest patch 24.18.0 as of 2026-06-23; fetched 2026-07-17) 2

  6. pgvector README, Docker section — https://github.com/pgvector/pgvector/blob/master/README.md#docker (docker pull pgvector/pgvector:pg18-trixie; pg18 and pg18-bookworm are aliases for the same image, while pg18-trixie is a separate image built on a different Debian base — same pgvector version between them, whichever version is current, but the two tag groups are not interchangeable; fetched 2026-07-17) 2

  7. Anthropic does not publish an embeddings API; Anthropic's own cookbook points to Voyage AI as a third-party embeddings provider — https://github.com/anthropics/claude-cookbooks/blob/main/third_party/VoyageAI/how_to_create_embeddings.md (checked 2026-07-17). This tutorial uses OpenAI's embedding endpoint instead, which is equally valid and doesn't require a Voyage AI account.

  8. PostgreSQL 18 release notes and versioning — https://www.postgresql.org/about/news/postgresql-18-released-3142/ and https://www.postgresql.org/about/news/postgresql-184-1710-1614-1518-and-1423-released-3297/ (PostgreSQL 18 released September 2025; patch release 18.4 on 2026-05-14; PostgreSQL 19 was still in beta — Beta 2 shipped 2026-07-16 — as of this post, so 18.x remains the current stable major version; fetched 2026-07-17)

  9. pgvector-node README — https://github.com/pgvector/pgvector-node (pgvector/pg subpath, registerTypes(), toSql() conversion, node-postgres usage examples; npm package pgvector@0.3.0, installed and executed directly, 2026-07-17) 2 3

  10. Anthropic, "Tool use with Claude" — https://platform.claude.com/docs/en/agents-and-tools/tool-use/overview (client-tool round trip: stop_reason: "tool_use", tool_use content blocks, tool_result response format, default tool_choice: "auto" steering via the tool's description field; fetched 2026-07-17) 2

  11. Anthropic, "Parallel tool use" — https://platform.claude.com/docs/en/agents-and-tools/tool-use/parallel-tool-use ("By default, Claude may call multiple tools in a single response"; "return one tool_result for each tool_use block, all together in the next user message"; disable_parallel_tool_use: true inside tool_choice — with type: "auto", this caps Claude at one tool call per response; fetched 2026-07-17)