~/DEVLOG.logn8n-whatsapp-bot-remember-customer-data.md FrankOS

How to stop your n8n WhatsApp bot from forgetting name and email

· 5 min read

Also in: Português · Español

While building an AI receptionist for real-estate agencies that answers on WhatsApp and Telegram, the first tests showed two failures that anyone who has built an agent in n8n has probably seen:

  1. The bot asked again for data it already had. The customer gave their name and email at the start and, a few messages later, the bot asked for them again.
  2. The bot confirmed a booking without all the data. It said “done, booked!” even though the phone number or the date was missing.

Both have the same cause, and the fix is not a better prompt.

The cause: state lives in the history, not in data

The quick way to build an agent in n8n is the AI Agent node with its built-in memory. That memory keeps the last messages of the conversation and hands them to the model on every turn.

The problem is that, there, the name and email are not data: they are loose sentences inside a history. The model has to find them again on every turn, and when the conversation grows and the memory window is trimmed, the first messages — exactly where the customer gave their name — are gone.

The second failure is similar: if the rule “never confirm without all the data” only lives in the prompt, the model follows it almost always. In a system that books real visits, “almost always” is not good enough.

The fix in four pieces

1. Conversation state in Postgres

Each conversation is a row in a sessions table, with the confirmed data in a slots column (JSONB) and the short history kept apart (the code keeps the Spanish names I use in the project):

CREATE TABLE sessions (
  id            BIGSERIAL PRIMARY KEY,
  tenant_id     INT  NOT NULL REFERENCES tenants(id),
  canal         TEXT NOT NULL,              -- 'whatsapp' | 'telegram'
  canal_user_id TEXT NOT NULL,              -- número o chat_id
  slots         JSONB DEFAULT '{}',         -- nombre, email, teléfono, propiedad, fecha...
  historial     JSONB DEFAULT '[]',         -- últimos turnos
  estado        TEXT  DEFAULT 'abierta',
  ultima_act    TIMESTAMPTZ DEFAULT now(),
  UNIQUE (tenant_id, canal, canal_user_id)
);

The workflow loads the session when a message arrives and saves it at the end with an upsert:

INSERT INTO sessions (tenant_id, canal, canal_user_id, slots, historial, ultima_act)
VALUES ($1, $2, $3, $4::jsonb, $5::jsonb, now())
ON CONFLICT (tenant_id, canal, canal_user_id)
DO UPDATE SET slots = $4::jsonb, historial = $5::jsonb, ultima_act = now();

historial keeps only the last 10–12 turns: what matters no longer depends on it.

2. Tell the model, explicitly, what it already knows

Before calling the model, a Code node turns the slots into text and puts it in the prompt. That way the name is not “somewhere in an earlier message” but in this turn’s instructions:

const s = $json.slots ?? {};
const requeridos = $json.reglas?.campos_requeridos
  ?? ['nombre', 'email', 'telefono', 'propiedad', 'fecha_hora'];

const conocidos = requeridos.filter(k => s[k]).map(k => `- ${k}: ${s[k]}`).join('\n') || '- (ninguno todavía)';
const faltantes = requeridos.filter(k => !s[k]);

const contexto = `
DATOS YA CONFIRMADOS DEL CLIENTE (NO los vuelvas a pedir):
${conocidos}

DATOS QUE AÚN FALTAN: ${faltantes.length ? faltantes.join(', ') : 'ninguno'}

Pide UN dato faltante por mensaje, el primero de la lista.
`;

return [{ json: { ...$json, contexto_slots: contexto, faltantes } }];

The list of required fields comes from each customer’s configuration, not from the code: every agency can ask for different data.

3. JSON output, not free text

At the end of the prompt, the model gets a mandatory format. Besides the message for the customer, it returns the new data it extracted and the action it proposes:

Responde SIEMPRE y ÚNICAMENTE con un objeto JSON válido, sin markdown:
{
  "mensaje": "texto a enviar al cliente",
  "slots_actualizados": { "nombre": "...", "email": "..." },
  "accion": "responder" | "verificar_disponibilidad" | "agendar" | "escalar",
  "confianza": 0.0-1.0
}

With that, the customer’s data stops being “text the model remembers” and becomes fields the workflow stores in slots.

4. The important rule, in code

The last step validates the model’s output before doing anything. If the model wants to book and some data is missing, the code blocks it, whatever the prompt says:

let r;
try {
  r = JSON.parse($json.output.replace(/```json|```/g, '').trim());
} catch (e) {
  return [{ json: { accion: 'escalar', mensaje: 'Un momento, te paso con un asesor.' } }];
}

const slots = { ...($json.slots ?? {}), ...(r.slots_actualizados ?? {}) };
const faltantes = ($json.reglas?.campos_requeridos
  ?? ['nombre', 'email', 'telefono', 'propiedad', 'fecha_hora']).filter(k => !slots[k]);

// bloqueo duro: no se agenda con datos incompletos
if (r.accion === 'agendar' && faltantes.length > 0) {
  r.accion = 'responder';
  r.mensaje = `Perfecto. Para cerrar el agendamiento me falta un dato: ¿me confirmas ${faltantes[0]}?`;
}
// poca confianza → a un humano
if (r.confianza !== undefined && r.confianza < 0.5) r.accion = 'escalar';

return [{ json: { ...$json, ...r, slots, faltantes } }];

If the JSON can’t be parsed, the conversation goes to a human instead of improvising.

How to test it

The test I use is exactly the case that failed:

  1. Give name and email in the first message.
  2. Talk about something else for several turns (price, location, photos).
  3. Ask for a visit.

The bot must not ask for the name or the email again, and must not confirm the booking until it has every required field. If it fails, the sessions table shows exactly what was stored at that moment — something the built-in memory doesn’t let you see.

What I take away

  • Conversation state is data, not something the model remembers. It belongs in a database you can query.
  • Business rules that must never break go in code. The prompt is for tone and guidance, not for guarantees.
  • The model proposes, the workflow decides. Asking for JSON and validating it turns a free-form chat into a controllable process.

This pattern is the base of the AI receptionist I’m validating with real-estate agencies in Brazil. If you’re building something similar in n8n and get stuck, get in touch.

← Devlog · back to FrankOS