---
title: Give an AI Agent Long-Term Memory with pgvector (2026)
description: 'Build a Claude agent with long-term memory: Postgres + pgvector store facts, cosine-similarity recall injects them back — verified across a process restart.'
url: https://nerdleveltech.com/ar/pgvector-agent-memory-typescript-tutorial
language: en
date: '2026-07-17'
category: ai-ml
tags:
  - pgvector
  - postgresql
  - ai agents
  - typescript
  - vector database
  - agent memory
  - tool use
  - claude
markdown_url: https://nerdleveltech.com/ar/pgvector-agent-memory-typescript-tutorial.md
attribution: 'Source: NerdLevelTech — https://nerdleveltech.com/ar/pgvector-agent-memory-typescript-tutorial'
terms: https://nerdleveltech.com/llms.txt
note: No Arabic translation of this page is published yet; this is the English text, as shown on the Arabic page.
---

# 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 restarted[^1].

## TL;DR

You'll build a CLI agent that pairs Claude (`claude-sonnet-5`) with a Postgres 18 + pgvector memory store[^2]. 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-small`[^3] and pulls the closest matching memories with pgvector's `<=>` cosine-distance operator[^4], injecting them into the system prompt. Stop the process, start it again, and the agent still knows your name. Runtime: Node.js 24 LTS[^5], `@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-installed[^6]
- 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-30[^5]
- 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 in[^6]:

```bash
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 builds[^2][^8]; after Step 2, `SELECT extversion FROM pg_extension WHERE extname = 'vector';` tells you exactly which patch you got. Confirm the container is up:

```bash
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.

```sql
-- 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 size[^3]. 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 exists[^4]. Apply it:

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

## Step 3: Set up the TypeScript project

```bash
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` client[^9]. Add `"type": "module"` to `package.json`, then create `tsconfig.json`:

```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:

```typescript
// 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.

```typescript
// 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 docs[^9]. `<=>` is cosine *distance*, so smaller is more similar; `ORDER BY ... ASC` already puts the closest matches first, no conversion to similarity needed[^4].

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

```typescript
// 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 text[^10]. 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 one[^11]. 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

```typescript
// 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 use[^9]. The `schema.sql` syntax was cross-checked line-by-line against pgvector's official README[^4].

**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:

```text
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:

```text
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:

```bash
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;"
```

```text
 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 design[^4]. 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 description[^10]. 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 2026](https://nerdleveltech.com/pgvector-hnsw-postgres-18-production-tuning-tutorial) — `ef_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)](https://nerdleveltech.com/cosine-similarity-vs-dot-product-embeddings-typescript) 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)](https://nerdleveltech.com/claude-memory-tool-context-editing-typescript-tutorial).

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](https://github.com/pgvector/pgvector#hybrid-search), which apply just as well to agent memories as they do to document search.

## Related reads

- [Mastra Durable Agents: A 2026 TypeScript Tutorial](https://nerdleveltech.com/mastra-durable-agents-typescript-tutorial)
- [Postgres Dead Tuples Not Being Removed: Fixes (2026)](https://nerdleveltech.com/postgres-dead-tuples-not-being-removed)

[^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)
[^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
[^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)
[^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)
[^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)
[^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)
[^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)
[^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)
