Module 3: Memory: The Agent That Remembers

8. Mini-project: conversational agent with persistent per-user memory

Description

By the end of this lesson you'll be able to build, start to finish, a conversational agent that recognizes the same customer across different sessions — today and three days from now, in the same tab or a new one — and you'll be able to prove it with verifiable evidence: rows in a Postgres table that survive a real restart of n8n's container, not just a response that "looks right" in the chat.

This matters because the scenario you're going to build is, almost word for word, what any real support team asks for: a customer writes today about an order, and expects that the next time they talk to the bot — tomorrow, next week, from another device — they won't have to repeat everything from scratch. An agent that only remembers within the same browser tab doesn't solve that case, no matter how well it answers in the demo in front of your team.

Connection to the module: this is the module's last lesson, and the goal is to assemble, not to learn a new piece. You're going to use the sessionKey and contextWindowLength you configured in lessons 3 and 4, the customer's stable identity (lesson 4) instead of the ephemeral id the chat widget generates, lesson 5's criterion for designing multi-turn conversations, and — by confirming the context window stays capped even though Postgres saves everything — the same principle you resolved in lessons 6 and 7 about not letting the history grow unchecked. None of this is new theory: today it runs on your own instance.

From room key to loyalty account

Think of a hotel with two different systems for keeping track of its guests. The first is the room key: for the length of your stay, that key opens your door and the housekeeping staff knows what to bring you because they follow the same reservation. But as soon as you check out, that key stops working for anything. If you come back next month, they give you a different room, a different key, and nobody at the front desk recognizes you've stayed there before — even though somewhere in a filing cabinet there's a folder with thousands of stays saved. The second system is the loyalty program account: a number that identifies you, the person, no matter which room you sleep in or how many times you return. With that number, the hotel can tell you "last time you asked for an extra pillow," even if three months and two lobby renovations have passed.

Everything you built in this module so far are loose pieces of those two systems. Today you assemble them into a single agent and, most importantly, you're going to verify with your own hands that the persistent piece works like the loyalty program and not like the room key: you're going to shut down n8n's process halfway through the test, and the agent is going to keep recognizing the same customer on the other side. Concretely, today you're going to assemble:

  • The customer's stable identity as sessionKey — the phone number, not the ephemeral id the widget generates on every visit (lesson 4).
  • The Postgres Chat Memory node with a real table, instead of Simple Memory living in the process's RAM (lesson 4).
  • A capped contextWindowLength, applying the same criterion of not letting the history grow unchecked you saw in lessons 6 and 7.
  • A multi-turn conversation that genuinely depends on what was said before, as you designed in lesson 5.

Worked example: TuTienda, persistent memory start to finish

You're going to go back to the TuTienda support agent you've been following since Module 1 — the one that answers about order #4521 — and leave it running with real persistent memory.

Step 1 — add Postgres to your self-hosted instance. You already have Module 1 lesson 7's self-hosted instance running, with its docker-compose.yml and its fixed N8N_ENCRYPTION_KEY. Add it a Postgres service dedicated to saving the chat history — don't reuse n8n's internal database, which is for its own operation, not for your application data:

# docker-compose.yml — the same one from Module 1 lesson 7, with Postgres added
services:
  n8n:
    image: docker.n8n.io/n8nio/n8n
    restart: unless-stopped
    ports:
      - "5678:5678"
    environment:
      - GENERIC_TIMEZONE=America/Mexico_City
      - TZ=America/Mexico_City
      - N8N_ENCRYPTION_KEY=${N8N_ENCRYPTION_KEY}
      - N8N_RUNNERS_ENABLED=true
      - N8N_ENFORCE_SETTINGS_FILE_PERMISSIONS=true
    volumes:
      - n8n_data:/home/node/.n8n
    depends_on:
      - postgres

  postgres:
    image: postgres:16
    restart: unless-stopped
    environment:
      - POSTGRES_USER=n8n_memory
      - POSTGRES_PASSWORD=${POSTGRES_PASSWORD}
      - POSTGRES_DB=chat_memory
    volumes:
      - postgres_data:/var/lib/postgresql/data

volumes:
  n8n_data:
  postgres_data:

Add the password to the same .env where you already have N8N_ENCRYPTION_KEY:

# .env
N8N_ENCRYPTION_KEY=the_one_you_already_had
POSTGRES_PASSWORD=put_your_own_password_here
docker compose up -d

What to expect: Docker downloads the official Postgres image and spins up a second container next to n8n's, without touching the n8n_data volume you already had. Notice the postgres service doesn't publish any port to your machine (ports:) — on purpose. Only n8n needs to talk to it, and it does so within the private network Docker Compose automatically creates between services in the same file. There, every service can resolve the others by name, like an internal contact list: n8n's container is going to be able to reach Postgres's using literally the hostname postgres, not localhostlocalhost, seen from inside n8n's container, refers to n8n's own container, not the one next door.

Step 2 — create the Postgres credential in n8n. In the editor, go to Credentials → New → Postgres, and fill in:

Host      = postgres          # the service name in docker-compose.yml, not "localhost"
Database  = chat_memory
User      = n8n_memory
Password  = the one you set in .env
Port      = 5432               # the container's internal port, no need to expose it
SSL       = Disable            # Docker's private network, not exposed to the internet

Step 3 — build the get_order_status tool with no dependency on any external API. For this mini-project you don't need TuTienda's real API (it doesn't exist) or any third-party account — use a Code Tool node with test data, connected to the AI Agent's ai_tool port:

# Node: Code Tool
name          = "get_order_status"
description   = "Use this tool when the customer gives an order number
                 and asks about its status or delivery date. Pass it
                 only the order number as input, with no extra text —
                 for example: 4521. Do not use it for questions about
                 exchange or return policy."
language      = JavaScript
// query arrives as plain text: the order number the model decided to send
const mockOrders = {
  "4521": { status: "in transit", eta: "July 24" },
  "4522": { status: "delivered", eta: "July 20" },
};

const order = mockOrders[query.trim()];

if (!order) {
  return `I couldn't find any order with the number ${query}.`;
}

return `Order #${query}: status ${order.status}, estimated delivery ${order.eta}.`;

Step 4 — connect the rest of the agent. A Chat Trigger, the same Chat Model you already configured since Module 1 (claude-sonnet-5 or whichever provider you use), and the TuTienda System Message you already know:

# Node: AI Agent
prompt.systemMessage = "You are the support assistant for TuTienda. Respond
                        in a warm, direct tone. If you don't have a piece
                        of data, say so — never make up order numbers or
                        delivery dates."

# ai_memory CONNECTION -> node: Postgres Chat Memory
memory.credential           = the Postgres credential from Step 2
memory.sessionKey           = "{{ $json.customerPhone }}"   # NOT the widget's sessionId
memory.tableName            = "n8n_chat_histories"
memory.contextWindowLength  = 10

The field that's easiest to overlook: the Session Key source selector on the Postgres Chat Memory node defaults to picking up the id the Chat Trigger builds for every conversation — exactly the ephemeral scope that failed in Configuration B of lesson 1. You have to switch it to manual expression mode and deliberately write {{ $json.customerPhone }}. Connecting the right node isn't enough if you leave that field at its default value — you're going to confirm this in Exercise 1.

Since customerPhone isn't generated by the chat widget (it only generates chatInput and sessionId), you're going to activate the workflow and send it messages via HTTP directly, with that field added by hand to the request body. Activate the workflow (the switch at the top right of the canvas) and open the Chat Trigger node: copy the value shown in the Chat URL field, Production tab — don't reconstruct it from memory, the identifier is unique per workflow.

Step 5 — turn 1 and turn 2, same day, same customer:

curl -X POST "<your Chat URL, Production tab>" \
  -H "Content-Type: application/json" \
  -d '{
    "action": "sendMessage",
    "sessionId": "tab-a1b2c3",
    "customerPhone": "+1-555-8811-2299",
    "chatInput": "Where is my order #4521?"
  }'

What to expect:

{ "output": "Your order #4521 is in transit, with an estimated delivery of July 24." }

Fifteen seconds later, same sessionId, same customerPhone:

curl -X POST "<your Chat URL, Production tab>" \
  -H "Content-Type: application/json" \
  -d '{
    "action": "sendMessage",
    "sessionId": "tab-a1b2c3",
    "customerPhone": "+1-555-8811-2299",
    "chatInput": "And when does it arrive?"
  }'
{ "output": "It arrives July 24 — that's the same order #4521 we were just talking about." }

So far you haven't tested anything new: it's the same turn-to-turn continuity you already saw in lesson 5 of Module 1. What comes next is the genuinely new part.

Step 6 — simulate "the next day" with a real restart, not an assumption. Restart only n8n's container (Postgres stays running, the same way your database server would keep running in production even if you redeploy your application):

docker compose restart n8n

Wait for it to come back up (docker compose logs -f n8n, until you see the "Editor is now accessible" line again), and send turn 3 with a new sessionId — simulating the customer opening a different tab or coming back the next day — but the same customerPhone:

curl -X POST "<your Chat URL, Production tab>" \
  -H "Content-Type: application/json" \
  -d '{
    "action": "sendMessage",
    "sessionId": "tab-x9y8z7",
    "customerPhone": "+1-555-8811-2299",
    "chatInput": "Hi, has my order arrived yet?"
  }'

What to expect:

{ "output": "Hi again. Based on what we discussed, your order #4521 was still in transit, estimated delivery July 24. Want me to check it again to confirm the current status?" }

This turn's sessionId never existed before — the n8n process that generated it isn't even the same process that handled turns 1 and 2, because you restarted it in the previous step. The only thing connecting this conversation to "yesterday's" was customerPhone, read from a Postgres table that never went down. Also notice something you already saw as a conceptual mistake in lesson 1: the agent responds with what was said, it doesn't call the tool again to verify the current status — that's why it offers to check it again instead of asserting it's still in transit. Come back to that point at this lesson's close.

Step 7 — confirm isolation between customers. Send a turn with a different customerPhone, any sessionId:

curl -X POST "<your Chat URL, Production tab>" \
  -H "Content-Type: application/json" \
  -d '{
    "action": "sendMessage",
    "sessionId": "tab-new",
    "customerPhone": "+1-555-0000-1111",
    "chatInput": "Has my order arrived yet?"
  }'

What to expect:

{ "output": "Happy to help. Could you share the order number so I can look into it?" }

Zero trace of order #4521. That's the correct result: this is a different customer, with a different customerPhone, and persistent memory doesn't mix one's history with the other's — the privacy problem lesson 5 of Module 1 warned about with a poorly chosen sessionKey.

How to confirm memory survived, and what it doesn't guarantee

The chat's responses are a good sign, but they're not proof — exactly the same principle from Module 1's mini-project: a response that "sounds right" doesn't confirm what happened internally. The real proof is in the database.

docker compose exec postgres psql -U n8n_memory -d chat_memory -c "\dt"

What to expect: a table called n8n_chat_histories — the name you put in memory.tableName — created automatically by the node the first time it ran, without you having to write any CREATE TABLE.

docker compose exec postgres psql -U n8n_memory -d chat_memory -c "SELECT * FROM n8n_chat_histories ORDER BY id;"

What to expect: one row per saved message — the customer's and the agent's count as separate rows. Look at the column that identifies the session: for turns 1, 2, and 3 (the three from the same customerPhone), that column carries the same value — +1-555-8811-2299 — regardless of the sessionId having changed between turn 2 and turn 3. For the Step 7 turn, you're going to see a different value in that same column. That's your direct confirmation, at the data source, that the scope was correctly configured — not an assumption based on the chat responding the way you expected.

There's one more check left, and it connects directly to lessons 6 and 7. With contextWindowLength = 10, the table can keep growing with no limit — every new message always gets saved — but that doesn't mean the model receives, on every call, every saved message. If you open that turn's execution panel and look at the connected Chat Model node, the message array that reached it is never going to carry more than 10 previous turns, regardless of the table having already accumulated 30. That's the difference between storage (unlimited, you resolved it in lesson 4) and context window (deliberately capped, lessons 6 and 7's criterion) — the two decisions working together, not one substituting for the other.

Common mistakes

Confusing "Postgres saves everything" with "the model sees everything" (conceptual). What happens: someone checks the n8n_chat_histories table, sees it has hundreds of rows from a long conversation, and assumes the model is reasoning over the complete history on every turn — and is surprised when the agent "forgets" something the customer said 20 turns ago. Why it happens: storage and context window solve different problems, even though both live in the same memory node. Postgres Chat Memory saves every message with no limit because that's its job — persisting; but contextWindowLength decides how many of those messages get reinjected on this turn's call, and that number does have a fixed ceiling. How to spot it: compare the number of rows in the table with the number of messages that actually reach the Chat Model in the execution panel — they're almost never going to match in a long conversation, and it's fine that they don't. How to fix it: if the agent needs to remember something from 20 turns ago, the answer isn't raising contextWindowLength with no limit (that reintroduces lesson 6's problem) — it's the summarization criterion you already saw in lesson 7.

Putting localhost as the Host in the Postgres credential (practical). What happens: you save the credential, connect it to the Postgres Chat Memory node, and the first execution fails with a connection-refused error, even though the Postgres container is running and healthy. Why it happens: localhost, evaluated from inside n8n's container, points to n8n's own container — which has no Postgres server listening on port 5432. The two containers are different machines within the Docker Compose network, even though they run on your same laptop. How to spot it: the error message usually says something like "connection refused" pointing at the configured host and port — check which hostname you put in before anything else. How to fix it: use the service name exactly as it appears in docker-compose.yml (postgres in this lesson's example) — that's the hostname Compose's internal network resolves.

Leaving the Postgres Chat Memory node's Session Key at its default value (practical, and the one that most resembles a "mysterious" memory failure). What happens: you built everything correctly — Postgres running, valid credential, table creating itself — but turn 3 (new sessionId, same customer) still asks for the order number again, as if persistent memory had done nothing. Why it happens: if you never changed the Session Key's source selector, the node keeps picking up the Chat Trigger's ephemeral sessionId instead of your {{ $json.customerPhone }} expression — exactly the same scope mistake from lesson 1's Configuration B, just now running on a backend that is persistent. How to spot it: check the table with psql — you're going to see that rows did get saved for turn 3, but under a session value different from turns 1 and 2 (the sessionId, not the phone number). How to fix it: open the Postgres Chat Memory node and explicitly confirm the Session Key is in manual expression mode pointing to customerPhone, not in automatic mode connected to the Chat Trigger.

Exercises

Exercise 1 — Diagnosis with contradictory evidence. You built your agent exactly as in this lesson. Turns 1 and 2 work fine. You restart n8n. Turn 3, with a new sessionId and the same customerPhone, the agent responds asking for the order number again — as if it had never talked to this customer. You check the table with psql and you do find turns 1 and 2's rows, correctly saved. What do you suspect first, and which field of the Postgres Chat Memory node would you check?

See solution

Primary suspect: the Postgres Chat Memory node's Session Key is still at its default value (taken from the Chat Trigger) instead of pointing to {{ $json.customerPhone }}. The clue is that the table does have the data — storage works, so it's not a "memory doesn't persist" problem. The problem is scope: if the Session Key used turns 1 and 2's sessionId, those rows ended up indexed under that ephemeral value. Turn 3 arrives with a different sessionId, looks up that new key, finds nothing, and the agent starts from zero — even though yesterday's history is still perfectly saved under the wrong key.

Why it works: it's the same diagnosis from lesson 1 applied with real data in front of you — separating "is the data saved?" (storage, confirmed with psql) from "is it saved under the correct identity?" (scope, confirmed by checking the node's Session Key). A persistent backend doesn't protect against a poorly chosen sessionKey.

Exercise 2 — Read the table to confirm scope, without guessing. After running turns 1, 2, 3, and the different customer's turn (Step 7) from this lesson, how many distinct values would you expect to see in the column identifying the session, if the Session Key was correctly configured pointing to customerPhone? And if, due to Exercise 1's mistake, it had ended up pointing to the Chat Trigger's sessionId?

See solution

Correctly configured (Session Key = customerPhone): two distinct values total — one for +1-555-8811-2299 (groups turns 1, 2, and 3, even though the sessionId changed between them) and another for +1-555-0000-1111 (Step 7's customer). The number of distinct sessions matches the number of real customers, not the number of times someone opened a new tab.

Misconfigured (Session Key = Chat Trigger's sessionId): three distinct values — one per sessionId used (tab-a1b2c3 for turns 1 and 2, tab-x9y8z7 for turn 3, and Step 7's), even though two of those three correspond to the same real customer.

Why it works: counting distinct values in that column is a direct way to verify scope without depending on whether the chat's response "sounded" correct — if the number of distinct sessions is higher than the number of real customers you tested, the Session Key is grouping by something more ephemeral than the customer's identity.

Exercise 3 — Apply the full criterion to a new case. You're going to build an internal HR agent that answers questions about each employee's vacation balance. Employees write in from Slack, which gives every person a stable user_id — the same today, next week, and from any channel where they message the bot. Design, with this module's full criterion: (a) what you'd use as sessionKey and why, (b) what type of memory you'd connect (Simple Memory or something backed by a database?) and why.

See solution

(a) The stable user_id Slack provides — it plays the same role as TuTienda's customerPhone in this lesson: a real identity for the person, which doesn't change between one conversation and the next, unlike a session or channel id that could change.

(b) Persistent memory backed by a database (Postgres Chat Memory or an equivalent option), not Simple Memory. Lesson 1's criterion applies directly: an employee who asks about their balance today and asks again next week expects the bot to remember that previous conversation's context — that's exactly the case where session memory isn't enough, no matter how well you configure contextWindowLength.

Why it works: the criterion doesn't change between TuTienda and HR — it's still "is there a stable real-world identity the agent needs to recognize across different sessions?" The domain changes, not the reasoning.

Summary and next step

Today you built, start to finish, the agent lesson 1's map promised: a customer's stable identity as sessionKey, history in a Postgres table that survived a real restart of n8n's container, and a context window that stays capped even as storage keeps growing. And you verified it with evidence — rows in a database and a shut-down container in between — not with a response that sounded reasonable in the chat.

Before moving on you should be able to: explain, pointing to the exact field on the Postgres Chat Memory node, why an agent can have persistent memory connected and still fail with the same symptom as an agent with no memory at all; read a chat-history table and count how many real sessions it represents, without guessing from the bot's responses; and design, for a new case, what identity to use as sessionKey and what kind of storage fits it.

There's something this mini-project deliberately doesn't test. When the customer comes back the next day and the agent repeats "your order is in transit," that data comes from memory — from what was said yesterday — not from a fresh query to any real system. If the order was actually delivered this morning, your agent would still have no idea, because memory stores conversation, not system state. That's exactly the boundary you're going to draw in Module 4, when you connect memory and real tools working together: an agent that remembers what was discussed, but also knows when to ask the real system again instead of trusting what it already said.

Resources