01 “Memory” is the wrong metaphor

When you configure an AI Agent in n8n for the first time, you see a “Memory” sub-node with options such as Window Buffer, Motorhead, and Zep. The documentation says to “add memory so the agent remembers the conversation context.” It sounds logical — a person talks to a bot, and the bot should “remember” what has already been said.

That is the trap. An LLM has no memory. An LLM is a stateless function that receives the full context and returns one continuation. “Memory” is simply a way to inject previous messages into the LLM context. That’s all.

The problem with this metaphor is that it hides what is actually happening. The developer thinks the agent “understands” the conversation history, “tracks” the customer state, and “remembers” what it showed. But each time, the LLM rereads the text transcript and infers the context from it.

For a chatbot that answers questions, this works. For a sales agent that must move a customer through eight funnel stages, it does not.

Core thesis of this article

A sales agent needs explicit session state — a data structure in the database that code reads, updates, and saves. The LLM receives a compact representation of this state in the prompt, generates text, and the code extracts state updates from the response. Memory in the sense of “the LLM remembers something” does not exist — and is not needed.

02 What Window Buffer Memory actually does

Let’s look at the mechanics. In n8n, Window Buffer Memory stores messages in a table that n8n creates automatically:

CREATE TABLE n8n_chat_histories ( id SERIAL PRIMARY KEY, session_id VARCHAR(255) NOT NULL, message JSONB NOT NULL );

Each message, from both the customer and the bot, is stored as a separate row. The next time the AI Agent runs, n8n reads the last N rows (I used 10), turns them into a ChatMessage[] array and attaches it to the LLM context.

The actual prompt the LLM receives looks roughly like this:

// This is what the LLM actually sees { "messages": [ { "role": "system", "content": "You are a Puramur consultant..." }, // 3500 tokens { "role": "user", "content": "Hi" }, { "role": "assistant", "content": "Hello! How can I help?" }, { "role": "user", "content": "I have a Sphynx cat" }, { "role": "assistant", "content": "Sphynx cats have sensitive skin..." }, { "role": "user", "content": "Recommend a shampoo" }, { "role": "assistant", "content": "I recommend Exotic Spa..." }, { "role": "user", "content": "How much does it cost?" } // current message ] }

The LLM sees the entire history as text. To answer “How much does it cost?”, it has to reconstruct from the text: which product is being discussed (Exotic Spa), which animal it is for (a Sphynx), and which price to look up. The LLM can do this — but it is a probabilistic process, not a guaranteed one.

03 Three real problems this created in my v1

Problem #1: The LLM reconstructs state from text unreliably

In 8 out of 10 cases, the LLM “remembers” correctly. In 2 out of 10, it does not. Especially when:

  • The conversation is long and a critical detail appeared eight messages ago, outside the window
  • The customer says something ambiguous (“okay, I’ll take the first one”) — the LLM loses track of what “first” refers to
  • The customer changes their mind (“not that one, the other one”) — the LLM keeps both options in context
  • There is a pause and then the customer returns — the LLM has no explicit “new topic” signal

Here is a real excerpt from my v1:

// Thread: session 1227845053 USER: I have a Yorkie and need something for a glossy coat BOT: I recommend Brilliant White (PR243487) — a gentle shampoo for light coats + Extra Gloss serum... USER: What about the price? BOT: [searches the KB, finds several products] Brilliant White starts at UAH 340, Extra Gloss — at UAH 380. Please confirm the exact price with a manager. USER: I’ll take it BOT: [does not understand which one — Brilliant White or Extra Gloss] Great choice! Could you tell me more about what exactly you are interested in?

“I’ll take it” is a coreference to the previous message. The LLM sees two products in the text and has no structured context indicating whether the customer chose both, the first, or the second. It guesses. It guesses wrong. The customer closes the chat.

Problem #2: Token usage grows with conversation length

Every LLM call is billed by tokens. In my v1, an average conversation had 8–12 messages before handoff to a manager. Here is how the prompt grew:

Turn System prompt History User msg Total (input)
135000153515
33500~300153815
53500~700154215
103500~1800155315
153500~3000 (trimmed to 10 msgs)156515

In long conversations, each turn cost almost twice as much as the first one. On top of that, history consumes the LLM’s attention. After reading 1,800 tokens of previous messages, the LLM follows the current context and specific system-prompt instructions less reliably.

In v2, with explicit state, every turn looks like this:

System: 400-800 tokens (route-specific) State summary: 150 tokens (compact JSON) User message: 15 tokens Total: ~600-1000 tokens on any turn.

Stable, predictable, and 3–5× cheaper on long conversations.

Problem #3: No observability

This was the most painful part. In v1, I could not answer simple questions about how the bot was performing:

  • How many sessions are active right now, and at which stages?
  • Where do customers drop off most often?
  • How long does a conversation take on average before manager handoff?
  • What percentage of conversations reach a product recommendation?
  • Which products does the bot recommend most often, and which ones actually get purchased?

All of this was “somewhere in the LLM’s head.” An analyst could say “look at the message history,” but that is text, not structure. You cannot write useful SQL over it.

04 State-first architecture

In v2, I inverted the model. Now state is a data structure in Postgres, not “something inside the LLM.” Each cycle looks like this:

Trigger (Telegram/Widget) ↓ Save User Message (Postgres — conversation log) ↓ LOAD Session State (Postgres) ←── read current state ↓ Router + Business Logic ←── code decides what to do ↓ Build Route-Specific Prompt ←── state → compact JSON in the prompt ↓ AI Agent (LLM) ←── LLM generates text + structured output ↓ EXTRACT State Updates ←── parse output, update state ↓ SAVE Session State (UPSERT) ←── save updated state ↓ Send Reply

The three key operations — LOAD → PROCESS → SAVE — are exactly what you would do in any normal database-backed web application. We simply added an LLM in the middle as a text generator.

Window Buffer Memory (v1)

State: in the LLM’s “head” (probabilistic)

Format: conversation transcript

Read field: the LLM must “read and understand”

Update field: tell the LLM in the prompt

Observability: none

Cost: grows with conversation length

Session State (v2)

State: JSONB row in Postgres

Format: structured object

Read field: state.pet.breed

Update field: state.pet.breed = 'Sphinx'

Observability: SQL queries

Cost: stable

05 Full puramur_sessions_state DDL

Here is the complete table as it runs in Puramur production:

CREATE TABLE puramur_sessions_state ( session_id TEXT PRIMARY KEY, -- Stage machine stage TEXT NOT NULL DEFAULT 'greeting', stage_iteration INT NOT NULL DEFAULT 0, stage_history JSONB NOT NULL DEFAULT '[]', -- Customer and pet customer JSONB NOT NULL DEFAULT '{}', pet JSONB NOT NULL DEFAULT '{}', -- Funnel progress discovery_completeness INT NOT NULL DEFAULT 0, conversation_summary TEXT DEFAULT '', -- Sales tracking presented_products JSONB NOT NULL DEFAULT '[]', objections_raised JSONB NOT NULL DEFAULT '[]', -- Cart cart JSONB NOT NULL DEFAULT '[]', cart_item_count INT NOT NULL DEFAULT 0, cart_total NUMERIC(10,2) NOT NULL DEFAULT 0, cart_gift_eligible BOOLEAN NOT NULL DEFAULT FALSE, cart_free_delivery BOOLEAN NOT NULL DEFAULT FALSE, -- Language detected_language TEXT DEFAULT 'uk', response_language TEXT DEFAULT 'uk', -- Handoff handoff_channel TEXT, handoff_triggered_at TIMESTAMPTZ, handoff_reason TEXT, -- Timeline created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), last_message_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ); CREATE INDEX idx_sessions_stage ON puramur_sessions_state(stage); CREATE INDEX idx_sessions_last_msg ON puramur_sessions_state(last_message_at DESC); CREATE INDEX idx_sessions_handoff ON puramur_sessions_state(handoff_triggered_at) WHERE handoff_triggered_at IS NOT NULL;

Let’s break down the key fields.

stageTEXT

Current stage of the sales funnel. One of: greeting, discovery, presentation, cart_building, contact_collection, handoff. This determines which prompt the LLM receives. In greeting, the instructions are simple: “say hello and ask whether it is a cat or a dog.” In presentation, the prompt includes live prices and expertise chunks. In handoff, it contains the final message.

stage_iterationINT

How many times the LLM has “returned” to the same stage. Used for exit conditions: if stage=objection_handling and stage_iteration ≥ 3, it is time to make a soft offer to connect the customer with a manager (“it may be better to connect you with a real person”).

stage_historyJSONB (array)

History of stage transitions. Format: [{"stage": "greeting", "exited_at": "2026-09-14T..."}, ...]. Used for funnel analysis — to see exactly where customers drop off.

customerJSONB (object)

Everything about the customer: {"name": "Anton", "phone": "+380661234567", "delivery_address": null, "preferred_time": null}. Updated progressively — when the customer gives their name and later their phone number.

petJSONB (object)

Pet profile: {"type": "cat", "name": "Murchyk", "breed": "Sphinx", "age_years": 3, "problems": ["dry_skin", "sensitive_skin"]}. This information determines which products the bot recommends. It is populated during the discovery stage.

discovery_completenessINT (0-100)

Profile completeness score. Formula: +25 pet.type, +25 pet.problems, +25 pet.breed, +15 pet.age, +5 pet.name, +5 customer.name. At ≥ 60, the flow automatically transitions to presentation. This is an explicit exit condition that controls the flow, instead of “the LLM will figure out when there is enough information.”

presented_productsJSONB (array)

SKUs the bot has already shown in this session. Format: [{"sku": "PR243587", "presented_at": "...", "stage_iteration": 1}, ...]. Used to avoid recommending the same products again during another presentation iteration.

cartJSONB (array)

The cart as an explicit structure. Format: [{"sku": "PR243587", "name": "...", "price": 277, "qty": 1, "line_total": 277, "added_at": "..."}, ...]. Plus a separate item with is_gift: true — Brilliant Gloss, which is added automatically when cart_total ≥ UAH 1000.

cart_total, cart_gift_eligible, cart_free_deliveryNUMERIC, BOOLEAN, BOOLEAN

Denormalized fields for quickly checking thresholds in business logic and the prompt. Technically, they could be calculated from the cart JSONB. But we insert ready-made booleans into the prompt so the LLM does not have to calculate.

handoff_triggered_atTIMESTAMPTZ

NULL until handoff is triggered. Once the customer is handed to a manager, it contains a timestamp. Used for idempotency: if handoff_triggered_at is not NULL, the manager is not notified again.

06 Why JSONB instead of separate columns

The obvious question is: why not do this instead?

CREATE TABLE puramur_sessions_state ( session_id TEXT PRIMARY KEY, customer_name TEXT, customer_phone TEXT, pet_type TEXT, pet_name TEXT, pet_breed TEXT, pet_age_years INT, pet_problems TEXT[], ... );

Three reasons I chose JSONB:

Schema flexibility. While building the sales agent, I added new fields to pet five times (initially only type + problems, then breed, age, and name). With separate columns, every change means a migration, ALTER TABLE, and updates to every SELECT/UPSERT query. With JSONB, I simply start writing the new field; old rows naturally have it as undefined.

A natural structure for LLM output. The LLM outputs JSON. My parser extracts it as an object. Writing that object as JSONB is one operation. Splitting it across 15 columns means format conversion on every cycle.

Atomic updates. UPSERTing a single JSONB field is atomic. Updating 15 columns in one query is also atomic, but the SQL becomes bulky and it is easy to make mistakes such as “update every field except one.”

When columns win: for fields you frequently filter or sort by. That is why stage, discovery_completeness, cart_total, cart_item_count, handoff_triggered_at are separate indexed columns. Everything else is JSONB.

Rule

Fields you use in WHERE or ORDER BY in analytical queries should be separate indexed columns. Fields you only read as a whole or pass to the LLM should be JSONB.

07 LOAD/SAVE patterns in n8n

LOAD — with an important gotcha

First the simplified version, then the important detail:

// Postgres node: "Load Session State" SELECT * FROM puramur_sessions_state WHERE session_id = $1; // Query Parameters (n8n expression) ={{ [$('Detect Language').first().json.session_id] }}

There is a critically important option, and it is not under Parameters but under Settings:

Gotcha #1

In the Postgres node, open Settings → Always Output Data → ON. Without this, when the session does not exist yet (a new session and SELECT returns 0 rows), the node stops execution. The next node never runs, and the workflow stalls.

With Always Output Data enabled, the node emits an empty item when rows = 0. The next Code node catches that and builds the default state:

// Code node: "Initialize State" const upstream = $('Detect Language').first().json; const rows = $input.all(); let state; if (rows.length === 0 || !rows[0].json.session_id) { // New session — build defaults state = { session_id: upstream.session_id, stage: 'greeting', stage_iteration: 0, stage_history: [], customer: { name: null, phone: null }, pet: { type: null, breed: null, problems: [] }, discovery_completeness: 0, presented_products: [], cart: [], cart_total: 0, is_new_session: true, // ... other fields with default values }; } else { // Existing session — parse JSONB fields from the row const row = rows[0].json; state = { session_id: row.session_id, stage: row.stage, stage_iteration: parseInt(row.stage_iteration) || 0, stage_history: row.stage_history || [], customer: row.customer || {}, pet: row.pet || {}, // ... and so on is_new_session: false, }; } return [{ json: { ...upstream, state }}];

Now state is a JavaScript object, which is then used by the router, prompt builder, and extract node. Each of them knows state is guaranteed to exist, with defaults where needed.

UPSERT — updates without race conditions

After the LLM call, we have updated state. It needs to be saved. Here is another gotcha:

INSERT INTO puramur_sessions_state ( session_id, stage, stage_iteration, stage_history, customer, pet, discovery_completeness, presented_products, cart, cart_item_count, cart_total, cart_gift_eligible, cart_free_delivery, detected_language, response_language, handoff_channel, handoff_triggered_at, handoff_reason, created_at, updated_at, last_message_at ) VALUES ( $1, $2, $3::int, $4::jsonb, $5::jsonb, $6::jsonb, $7::int, $8::jsonb, $9::jsonb, $10::int, $11::numeric, $12::boolean, $13::boolean, $14, $15, 'telegram', CASE WHEN $16::boolean THEN NOW() ELSE NULL END, $17, NOW(), NOW(), NOW() ) ON CONFLICT (session_id) DO UPDATE SET stage = EXCLUDED.stage, stage_iteration = EXCLUDED.stage_iteration, stage_history = EXCLUDED.stage_history, customer = EXCLUDED.customer, pet = EXCLUDED.pet, -- ... all fields handoff_triggered_at = COALESCE( puramur_sessions_state.handoff_triggered_at, -- preserve the old value if it exists EXCLUDED.handoff_triggered_at ), handoff_reason = COALESCE(EXCLUDED.handoff_reason, puramur_sessions_state.handoff_reason), updated_at = NOW(), last_message_at = NOW();

ON CONFLICT DO UPDATE — this is a Postgres UPSERT. It either creates a new row or updates the existing one. It is guaranteed to be atomic even if two messages arrive at the same time, for example when a customer sends two messages in quick succession.

Gotcha #2

For handoff_triggered_at we use COALESCE(existing, new) — meaning we preserve the old timestamp if one already exists. This matters because if handoff was triggered five messages ago, we do not want to overwrite the timestamp with every new message in post-handoff mode. This also guarantees idempotency notification idempotency: the code checks alreadyNotified = !!state.handoff_triggered_at before sending manager alerts.

Gotcha #3

In the n8n Postgres node, the value $json.state is not available in the next node after Postgres — executeQuery returns only the query result and does not pass the input through. So in Query Parameters you must explicitly reference the previous node that contains state: {{ $('Extract State Update').first().json.state.stage }}, not {{ $json.state.stage }}.

08 Observability queries

The main advantage of state as a structure is SQL. Here are five queries I run regularly in production.

Active sessions by stage

SELECT stage, COUNT(*) AS sessions FROM puramur_sessions_state WHERE last_message_at > NOW() - INTERVAL '24 hours' AND handoff_triggered_at IS NULL GROUP BY stage ORDER BY sessions DESC; -- Example result: -- stage | sessions -- discovery | 12 -- presentation | 8 -- cart_building | 3 -- contact_collection| 1 -- greeting | 1

You can immediately see where people get stuck. If presentation has far more sessions than cart_building, products are being shown but are not convincing enough. The presentation prompt needs improvement.

Sales funnel for the last 7 days

WITH stages_reached AS ( SELECT session_id, ARRAY_AGG(DISTINCT h->>'stage') AS stages, stage AS current_stage, handoff_triggered_at FROM puramur_sessions_state, jsonb_array_elements(stage_history) h WHERE created_at > NOW() - INTERVAL '7 days' GROUP BY session_id, stage, handoff_triggered_at ) SELECT COUNT(*) FILTER (WHERE 'greeting' = ANY(stages)) AS reached_greeting, COUNT(*) FILTER (WHERE 'discovery' = ANY(stages)) AS reached_discovery, COUNT(*) FILTER (WHERE 'presentation' = ANY(stages)) AS reached_presentation, COUNT(*) FILTER (WHERE 'cart_building' = ANY(stages)) AS reached_cart, COUNT(*) FILTER (WHERE handoff_triggered_at IS NOT NULL) AS converted_handoff FROM stages_reached; -- Example result: -- reached_greeting | 47 -- reached_discovery | 38 (81%) -- reached_presentation | 22 (47%) -- reached_cart | 8 (17%) -- converted_handoff | 5 (11%)

This is a real conversion funnel built from real conversations. You can immediately see the biggest drop-off: discovery → presentation, almost a 2× reduction. That means presentation triggering needs improvement — perhaps the discovery_completeness threshold is set too high.

Average time spent in each stage

WITH stage_durations AS ( SELECT session_id, h->>'stage' AS stage, (h->>'exited_at')::timestamptz - LAG((h->>'exited_at')::timestamptz) OVER (PARTITION BY session_id ORDER BY ordinality) AS duration FROM puramur_sessions_state, jsonb_array_elements(stage_history) WITH ORDINALITY h(h, ordinality) ) SELECT stage, AVG(EXTRACT(EPOCH FROM duration))::int AS avg_seconds, COUNT(*) AS samples FROM stage_durations WHERE duration IS NOT NULL GROUP BY stage ORDER BY avg_seconds DESC;

If presentation takes 15 minutes on average, the LLM is dragging it out. If discovery ends in 45 seconds, the profile collection is too shallow and should go deeper.

Most frequently recommended products

SELECT p->>'sku' AS sku, COUNT(*) AS times_presented, COUNT(*) FILTER (WHERE handoff_triggered_at IS NOT NULL) AS converted FROM puramur_sessions_state, jsonb_array_elements(presented_products) p WHERE created_at > NOW() - INTERVAL '30 days' GROUP BY sku ORDER BY times_presented DESC LIMIT 20;

This is the bot’s top 20 “favorites.” Compare them with actual CRM sales. If a SKU is recommended often but rarely purchased, investigate why — the prompt for that product may be weak, or the product may be mapped to the wrong customer problems.

Handoff reasons distribution

SELECT handoff_reason, COUNT(*) AS count, AVG(cart_total)::numeric(10,0) AS avg_cart, AVG(EXTRACT(EPOCH FROM handoff_triggered_at - created_at))::int AS avg_time_to_handoff_sec FROM puramur_sessions_state WHERE handoff_triggered_at IS NOT NULL AND handoff_triggered_at > NOW() - INTERVAL '30 days' GROUP BY handoff_reason ORDER BY count DESC; -- Example: -- handoff_reason | count | avg_cart | avg_time -- cart_confirmed | 42 | 850 | 640 sec -- explicit_request | 15 | 0 | 45 sec -- complaint | 3 | 0 | 25 sec

This is a report for a manager or CEO. In one month, 42 customers reached a confirmed cart with an average basket of UAH 850, and the bot handed them to a manager with a ready order. Fifteen were direct “I want an operator” requests, not all of which reached the cart. Three were complaints.

09 Practical takeaways

The key question is not “memory or no memory.” The key question is where your agent’s state lives. If state is “conversation text the LLM rereads every time,” you are building a reference-style consultant. If state is a database structure that code reads, updates, and saves, you are building a proper sales agent with observability, metrics, and predictable behavior.

Window Buffer Memory has its place — for chatbots that “talk about different topics,” demo agents, and prototypes. But in sales, where there is a specific funnel, specific exit conditions, and specific data that must be collected before handoff — explicit JSONB state always wins. This is not an architectural preference; it is a necessity.

Three things I would do in any AI agent from day one. First, create a separate session-state table with JSONB for flexible fields and regular columns for fields used in WHERE/ORDER BY. Second, make LOAD → PROCESS → SAVE an explicit workflow cycle, with Always Output Data enabled on the LOAD node. Third, from the first week, build SQL queries for funnel analysis, drop-offs, and time per stage. Without that, you do not know whether the agent “sells”; you only know that it “talks.”

Pre-deployment checklist
  • A separate table exists for session state (not n8n_chat_histories)
  • Key analytics fields are separate indexed columns
  • Flexible structures (customer, pet, cart, arrays) use JSONB
  • Load Session State node: Settings → Always Output Data → ON
  • The Init State Code node builds defaults for new sessions
  • UPSERT via ON CONFLICT DO UPDATE, not two separate queries
  • Use COALESCE for fields that must not be overwritten (handoff_triggered_at)
  • In the Postgres node, reference state via $('Extract State').first().json, not $json
  • At least three observability queries are configured on a dashboard

Next article in the series

#3 — Semantic Router in n8n: how to route requests into five paths
An article about embeddings + cosine search, threshold tuning with real numbers (6.7% → 93% accuracy), a Bruno test suite, and a Python workflow generator.

Read the next article →