Agent memory explained: short-term, long-term and a chatbot

15 min read
On this page

This post is about agent memory.

By default an agent has none. It answers your question, then forgets you exist. Ask “what’s my name?” right after telling it, and it won’t know.

We’ll fix that in two ways:

  • Short-term memory — remember this conversation.
  • Long-term memory — remember this person, across conversations.

Then we build a small chatbot that does both.

Here is the difference in one picture:

   SHORT-TERM                         LONG-TERM
   remembers a conversation           remembers a person

   ┌── thread: chat-A ──────┐         ┌──── user: ali ──────┐
   │  you: My name is Ali   │         │  - is vegetarian    │
   │  ai:  Hi Ali           │         │  - lives in Berlin  │
   │  you: what's my name?  │         │  - allergic: nuts   │
   │  ai:  You're Ali       │         └─────────────────────┘
   └────────────────────────┘          every chat can see it
     gone in a new chat

I’ll go through ten small lessons. Each one adds one idea. The first six build up memory. The last is a chatbot you can run.

The pieces you’ll use

A few names show up again and again:

  • createAgent — makes the agent.
  • checkpointer — saves a conversation so the agent can continue it.
  • thread_id — the id of one conversation.
  • user_id — the id of one person.
  • MemorySaver — a checkpointer that lives in RAM.
  • SqliteSaver — a checkpointer that lives on disk.
  • tool — a function the agent can call. We’ll give it a remember tool.
  • dynamicSystemPromptMiddleware — runs before the model, to add facts to the prompt.

Setup

mkdir memory-demo && cd memory-demo
npm init -y
npm pkg set type=module
npm install langchain @langchain/langgraph \
  @langchain/langgraph-checkpoint-sqlite @langchain/openrouter \
  better-sqlite3 zod

You need Node.js 20.6 or newer and an OpenRouter API key from openrouter.ai.

These few lines are shared by the lessons below.

import { createAgent, tool } from "langchain";
import { ChatOpenRouter } from "@langchain/openrouter";

// Paste your key here. Fine for a script on your own machine.
process.env.OPENROUTER_API_KEY = "sk-or-v1-paste-your-key-here";

const MODEL = new ChatOpenRouter({
  model: "openai/gpt-4o-mini",
  temperature: 0,
});

const ask = (agent, text, config) =>
  agent.invoke({ messages: [{ role: "user", content: text }] }, config);
const lastText = (result) => result.messages.at(-1).content;

Lesson 1: no memory (the problem)

Every invoke starts blank. Nothing carries over.

   invoke 1:  "My name is Ali."   ─► agent ─► "Nice to meet you"
   invoke 2:  "What is my name?"  ─► agent ─► "I don't know"
async function lesson1() {
  const agent = createAgent({ model: MODEL, tools: [] });

  await ask(agent, "My name is Ali.");
  const result = await ask(agent, "What is my name?");
  console.log("Agent:", lastText(result)); // "I don't know your name"
}

Two separate calls. The second one never saw the first. That’s the whole problem, and it’s what memory fixes.

Lesson 2: short-term memory

Give the agent a checkpointer. Now each conversation is saved under a thread_id.

  • Same thread_id → it remembers the conversation.
  • New thread_id → a blank slate.
            ┌──── checkpointer (saves each thread) ────┐
            │   chat-A:  [My name is Ali, ...]         │
            │   chat-B:  [ ... ]                       │
            └──────────────────────────────────────────┘
                     ▲                      ▲
   thread_id: chat-A ┘                      └ thread_id: chat-B
       remembers                              knows nothing
import { MemorySaver } from "@langchain/langgraph";

async function lesson2() {
  const agent = createAgent({
    model: MODEL,
    tools: [],
    checkpointer: new MemorySaver(), // short-term memory
  });

  const chatA = { configurable: { thread_id: "chat-A" } };
  const chatB = { configurable: { thread_id: "chat-B" } };

  await ask(agent, "My name is Ali.", chatA);

  console.log("Same thread:", lastText(await ask(agent, "What's my name?", chatA)));
  console.log("New thread: ", lastText(await ask(agent, "What's my name?", chatB)));
  // chat-A knows. chat-B doesn't. Memory is per thread.
}

The memory is tied to the thread_id, not to the agent. One agent can run many separate conversations.

Lesson 3: memory that survives a restart

MemorySaver lives in RAM. When the Node process stops, the memory is gone.

To keep it, save to disk. SqliteSaver writes to a file.

   MemorySaver  → lives in RAM   → gone when the process stops
   SqliteSaver  → lives on disk  → survives a restart  (threads.db)
import { SqliteSaver } from "@langchain/langgraph-checkpoint-sqlite";

async function lesson3() {
  const checkpointer = SqliteSaver.fromConnString("threads.db"); // on disk
  const agent = createAgent({ model: MODEL, tools: [], checkpointer });
  const config = { configurable: { thread_id: "persistent-chat" } };

  await ask(agent, "My name is Ali and I live in Berlin.", config);

  // Stop the program, start it again, and this still works:
  const result = await ask(agent, "Where do I live?", config);
  console.log(lastText(result)); // "Berlin"
}

Same idea as lesson 2. Only the storage changed: RAM became a file.

Lesson 4: a place to keep facts

Short-term memory remembers one chat. But some things should outlive the chat. Your name. Your diet. Your city.

Those are facts about a person, not about a conversation. So we store them by user_id, in a tiny table of our own.

   memories table
   ┌────┬─────────┬──────────────────┐
   │ id │ user_id │ fact             │
   ├────┼─────────┼──────────────────┤
   │ 1  │ ali     │ Is vegetarian    │
   │ 2  │ ali     │ Lives in Berlin  │
   └────┴─────────┴──────────────────┘
import Database from "better-sqlite3";

const memoryDb = new Database("memories.db");
memoryDb.exec(`
  CREATE TABLE IF NOT EXISTS memories (
    id         INTEGER PRIMARY KEY AUTOINCREMENT,
    user_id    TEXT NOT NULL,
    fact       TEXT NOT NULL,
    created_at TEXT DEFAULT CURRENT_TIMESTAMP
  )
`);

const saveMemory = (userId, fact) =>
  memoryDb
    .prepare("INSERT INTO memories (user_id, fact) VALUES (?, ?)")
    .run(userId, fact);

const loadMemories = (userId) =>
  memoryDb
    .prepare("SELECT id, fact FROM memories WHERE user_id = ? ORDER BY id")
    .all(userId);

const deleteMemory = (id) =>
  memoryDb.prepare("DELETE FROM memories WHERE id = ?").run(id);

Three small helpers: save, load, delete. No LangGraph here, just SQL.

Keep this straight in your head, because it’s the key idea:

   thread_id  → one conversation  → short-term memory
   user_id    → one person        → long-term memory

Lesson 5: let the agent save a fact

We don’t want to save everything. Small talk isn’t worth keeping.

So we give the agent a remember tool and let it decide. The model calls it only when the user shares a lasting fact.

   you: "I'm vegetarian and allergic to peanuts."
            │
            ▼
   the model notices two lasting facts
            │
            ▼
   calls remember("Is vegetarian")
   calls remember("Allergic to peanuts")
            │
            ▼
   two new rows in memories.db
import * as z from "zod";

const rememberTool = tool(
  async ({ fact }, config) => {
    const userId = config.configurable.user_id; // who to save it for
    saveMemory(userId, fact);
    return `Saved: "${fact}"`;
  },
  {
    name: "remember",
    description:
      "Save a durable fact about the user (diet, allergies, city, " +
      "preferences). Do NOT save small talk or one-off requests.",
    schema: z.object({
      fact: z.string().describe("Short fact, third person, e.g. 'Is vegetarian'"),
    }),
  },
);

async function lesson5() {
  const agent = createAgent({
    model: MODEL,
    tools: [rememberTool],
    systemPrompt: "When the user shares a lasting personal fact, call `remember`.",
    checkpointer: new MemorySaver(),
  });

  await ask(agent, "Hi! I'm vegetarian and allergic to peanuts.", {
    configurable: { thread_id: "t1", user_id: "ali" },
  });

  console.log(loadMemories("ali")); // the two facts are now saved
}

Notice the user_id in the config. The tool reads it to know whose memory to write. The description tells the model what is worth saving.

Lesson 6: use the facts in every chat

Saving facts is half the job. Now the agent has to use them.

Before every model call, we load the user’s facts and paste them into the system prompt. That’s what dynamicSystemPromptMiddleware is for. It runs before the model, every time, so it always sees the latest facts.

   new question
        │
        ▼
   before the model runs:
     load this user's facts from memories.db
     put them into the system prompt
        │
        ▼
   system: "You know: is vegetarian, lives in Berlin"
   user:   "Suggest a dinner idea."
        │
        ▼
   model → a vegetarian dinner
import { dynamicSystemPromptMiddleware } from "langchain";

function buildMemoryAgent() {
  return createAgent({
    model: MODEL,
    tools: [rememberTool],
    checkpointer: new MemorySaver(),
    contextSchema: z.object({ userId: z.string() }),
    middleware: [
      dynamicSystemPromptMiddleware((state, runtime) => {
        const userId = runtime.context?.userId ?? "anonymous";
        const facts = loadMemories(userId)
          .map((memory) => `- ${memory.fact}`)
          .join("\n");
        return [
          "You are a helpful assistant.",
          "Call `remember` when the user shares a lasting personal fact.",
          facts ? `\nWhat you know about this user:\n${facts}` : "",
        ].join("\n");
      }),
    ],
  });
}

async function lesson6() {
  const agent = buildMemoryAgent();
  const context = { userId: "ali" };

  // Chat 1: teach it something.
  await agent.invoke(
    { messages: [{ role: "user", content: "Remember I'm vegetarian." }] },
    { configurable: { thread_id: "chat-1", user_id: "ali" }, context },
  );

  // Chat 2: a brand-new thread, no shared messages.
  const result = await agent.invoke(
    { messages: [{ role: "user", content: "Suggest a dinner idea." }] },
    { configurable: { thread_id: "chat-2", user_id: "ali" }, context },
  );
  console.log(lastText(result)); // suggests a vegetarian dinner
}

This is the payoff. Chat 2 is a fresh conversation. It never saw chat 1. But because the fact lives under user_id, the agent still knows Ali is vegetarian. A different user_id would know nothing about Ali.

This is a middleware, the same idea as in the middleware post. It just runs your code before the model and adds to the prompt.

Lesson 7: smarter recall (optional)

Pasting all the facts works until they pile up. With fifty facts, most of them have nothing to do with the question.

A store with embeddings can fetch only the facts that mean something close to the question. (This needs @langchain/openai and uses your key for embeddings.)

   all facts:  vegetarian · Berlin · dog Rex · peanut allergy

   question: "What should I cook tonight?"
        │
        ▼
   search by meaning
        │
        ▼
   picks: vegetarian, peanut allergy    (not the dog, not the city)
import { InMemoryStore } from "@langchain/langgraph";
import { OpenAIEmbeddings } from "@langchain/openai";

async function lesson7() {
  const store = new InMemoryStore({
    index: {
      embeddings: new OpenAIEmbeddings({ model: "text-embedding-3-small" }),
      dims: 1536,
    },
  });

  const namespace = ["memories", "ali"];
  await store.put(namespace, "m1", { fact: "Is vegetarian" });
  await store.put(namespace, "m2", { fact: "Lives in Berlin" });
  await store.put(namespace, "m3", { fact: "Has a dog named Rex" });
  await store.put(namespace, "m4", { fact: "Allergic to peanuts" });

  const hits = await store.search(namespace, {
    query: "What should I cook tonight?",
    limit: 2,
  });
  hits.forEach((hit) => console.log(hit.value.fact, hit.score?.toFixed(2)));
  // "Is vegetarian" and "Allergic to peanuts" score highest
}

Same idea as a search engine. If you want the full picture of how embeddings and search work, I wrote a RAG-from-scratch post about exactly that.

Lesson 8: ask before you save

Memory you can’t trust is worse than no memory. Sometimes you want a human to confirm before a fact is written.

humanInTheLoopMiddleware pauses the agent before a chosen tool. The run stops. The state is saved. A human decides what happens next.

   agent wants to call  remember("Is vegetarian")
            │
            ▼
         PAUSE ⏸     (the checkpointer saves the paused run)
            │
      a human decides
       │        │          │
   approve     edit      reject
       │        │          │
    run as     run with   don't run,
    asked      a fix      tell it why
import { humanInTheLoopMiddleware } from "langchain";
import { Command, INTERRUPT, isInterrupted } from "@langchain/langgraph";

async function lesson8() {
  const agent = createAgent({
    model: MODEL,
    tools: [rememberTool],
    checkpointer: new MemorySaver(), // required: the pause must be saved
    systemPrompt: "Call `remember` when the user shares a lasting fact.",
    middleware: [
      humanInTheLoopMiddleware({
        interruptOn: { remember: { allowedDecisions: ["approve", "reject"] } },
      }),
    ],
  });

  const config = { configurable: { thread_id: "mem-hitl", user_id: "ali" } };

  // The agent wants to save a fact, so it pauses.
  let result = await ask(agent, "I'm vegetarian.", config);

  if (isInterrupted(result)) {
    const request = result[INTERRUPT][0].value.actionRequests[0];
    console.log("Save this fact?", (request.arguments ?? request.args).fact);
    console.log("DB before approval:", loadMemories("ali")); // still empty

    // A human approves. The run resumes from the saved pause.
    result = await agent.invoke(
      new Command({ resume: { decisions: [{ type: "approve" }] } }),
      config, // same thread_id = resume the same paused run
    );
    console.log("DB after approval:", loadMemories("ali")); // now saved
  }
}

The three decisions are just different resume commands:

// approve — run the tool as the agent asked
new Command({ resume: { decisions: [{ type: "approve" }] } });

// edit — fix the arguments first
new Command({ resume: { decisions: [{
  type: "edit",
  editedAction: { name: "remember", args: { fact: "Is vegan" } },
}] } });

// reject — don't run it; tell the agent why
new Command({ resume: { decisions: [{
  type: "reject",
  message: "Don't save that. Ask me first.",
}] } });

The same guard works on any risky tool, not just memory. Point it at a send_email tool and the agent has to get your approval before it sends anything.

Lesson 9: a forget button

People change. Facts go stale. A user must be able to delete what the agent “knows”.

That’s the deleteMemory helper from lesson 4. One row, gone.

   memories.db
     1  Is vegetarian
     2  Lives in Paris   ← wrong
            │
      deleteMemory(2)
            │
     1  Is vegetarian
async function lesson9() {
  saveMemory("ali", "Is vegetarian");
  saveMemory("ali", "Lives in Paris"); // wrong

  const wrong = loadMemories("ali").find((m) => m.fact.includes("Paris"));
  deleteMemory(wrong.id);

  console.log(loadMemories("ali")); // only "Is vegetarian" is left
}

Nothing clever. But every memory system needs it.

Lesson 10: the whole thing, a little chatbot

Now put it together into one chatbot you can run:

  • Short-term memory per chat, saved to disk (threads.db).
  • Long-term facts per person (memories.db).
  • Facts injected into every prompt.
  • Commands to start a new chat, list facts, and forget one.
   ┌─ short-term (per thread) ─┐   ┌─ long-term (per user) ─┐
   │  threads.db               │   │  memories.db           │
   │  the conversation so far  │   │  durable facts         │
   └─────────────┬─────────────┘   └───────────┬────────────┘
                 │                              │
                 └──────────► agent ◄───────────┘
                               │
                       every reply uses both

Save this as memory-bot.mjs.

// memory-bot.mjs
import { createAgent, tool, dynamicSystemPromptMiddleware } from "langchain";
import { SqliteSaver } from "@langchain/langgraph-checkpoint-sqlite";
import { ChatOpenRouter } from "@langchain/openrouter";
import Database from "better-sqlite3";
import readline from "node:readline/promises";
import * as z from "zod";

// Paste your key here. Fine for a script on your own machine.
process.env.OPENROUTER_API_KEY = "sk-or-v1-paste-your-key-here";

const MODEL = new ChatOpenRouter({
  model: "openai/gpt-4o-mini",
  temperature: 0,
});

// ---- long-term memory: a tiny facts table ----
const memoryDb = new Database("memories.db");
memoryDb.exec(`
  CREATE TABLE IF NOT EXISTS memories (
    id         INTEGER PRIMARY KEY AUTOINCREMENT,
    user_id    TEXT NOT NULL,
    fact       TEXT NOT NULL,
    created_at TEXT DEFAULT CURRENT_TIMESTAMP
  )
`);
const saveMemory = (userId, fact) =>
  memoryDb
    .prepare("INSERT INTO memories (user_id, fact) VALUES (?, ?)")
    .run(userId, fact);
const loadMemories = (userId) =>
  memoryDb
    .prepare("SELECT id, fact FROM memories WHERE user_id = ? ORDER BY id")
    .all(userId);
const deleteMemory = (id) =>
  memoryDb.prepare("DELETE FROM memories WHERE id = ?").run(id);

// ---- the tool the agent uses to remember a fact ----
const rememberTool = tool(
  async ({ fact }, config) => {
    saveMemory(config.configurable.user_id, fact);
    return `Saved: "${fact}"`;
  },
  {
    name: "remember",
    description:
      "Save a durable fact about the user (diet, allergies, city, " +
      "preferences). Do NOT save small talk or one-off requests.",
    schema: z.object({
      fact: z.string().describe("Short fact, third person, e.g. 'Is vegetarian'"),
    }),
  },
);

const userId = "ali";
let threadId = `chat-${Date.now()}`;

const agent = createAgent({
  model: MODEL,
  tools: [rememberTool],
  // short-term memory, saved to disk so a chat survives a restart
  checkpointer: SqliteSaver.fromConnString("threads.db"),
  contextSchema: z.object({ userId: z.string() }),
  // long-term memory, read fresh before every model call
  middleware: [
    dynamicSystemPromptMiddleware((state, runtime) => {
      const facts = loadMemories(runtime.context?.userId ?? userId)
        .map((memory) => `- ${memory.fact}`)
        .join("\n");
      return [
        "You are a friendly assistant with long-term memory.",
        "Call `remember` ONLY for lasting personal facts.",
        facts ? `\nKnown facts about the user:\n${facts}` : "",
      ].join("\n");
    }),
  ],
});

const terminal = readline.createInterface({
  input: process.stdin,
  output: process.stdout,
});
console.log(`User: ${userId} | Thread: ${threadId}`);
console.log("Commands: /new  /memories  /forget <id>  /quit\n");

while (true) {
  const input = (await terminal.question("You: ")).trim();
  if (!input) continue;

  if (input === "/quit") break;
  if (input === "/new") {
    threadId = `chat-${Date.now()}`;
    console.log(`New thread: ${threadId} (memories are still here!)\n`);
    continue;
  }
  if (input === "/memories") {
    console.table(loadMemories(userId));
    continue;
  }
  if (input.startsWith("/forget ")) {
    deleteMemory(Number(input.split(" ")[1]));
    console.log("Forgotten.\n");
    continue;
  }

  const result = await agent.invoke(
    { messages: [{ role: "user", content: input }] },
    {
      configurable: { thread_id: threadId, user_id: userId },
      context: { userId },
    },
  );
  console.log("Bot:", result.messages.at(-1).content, "\n");
}
terminal.close();

Running it

node memory-bot.mjs

Try this, and watch the memory work across chats:

You: I'm vegetarian and I love spicy food.
Bot: Got it, I'll remember that.

You: /new
New thread: chat-1760... (memories are still here!)

You: What should I cook tonight?
Bot: How about a spicy chickpea curry? ...

You: /memories
┌─────────┬────┬────────────────────┐
│ (index) │ id │ fact               │
├─────────┼────┼────────────────────┤
│ 0       │ 1  │ 'Is vegetarian'    │
│ 1       │ 2  │ 'Likes spicy food' │
└─────────┴────┴────────────────────┘

You: /quit

The /new chat is a clean conversation. It forgot the small talk. But it still cooked you something vegetarian and spicy, because those facts live with the user, not the chat.

About that hardcoded key

A key pasted into a script on your own machine is fine. A key in a script that ends up in a git repo is not. Bots scan public GitHub for keys.

Before you commit, drop the line that sets the key, and run like this:

node --env-file=.env memory-bot.mjs

Put OPENROUTER_API_KEY=sk-or-v1-... in a .env file, and add .env to .gitignore.

Where to go from here

  • Let the model clean up memory. Ask it to merge or update facts, not just add new ones, so you don’t end up with “lives in Paris” and “lives in Berlin” both saved.
  • Add a timestamp check. Old facts can expire.
  • Use lesson 7’s semantic search once a user has many facts.
  • Put the remember tool behind the lesson 8 guard, so nothing is saved without a yes.
  • Swap SQLite for Postgres when you outgrow a single file.

That’s the whole idea. Short-term memory remembers the chat. Long-term memory remembers the person. The agent uses both on every reply.


Share: