Enquanto construía uma recepcionista com IA para imobiliárias, que atende por WhatsApp e Telegram, os primeiros testes mostraram duas falhas que quem já montou um agente no n8n provavelmente conhece:
- O bot pedia de novo dados que já tinha. O cliente informava nome e email no começo e, algumas mensagens depois, o bot pedia tudo outra vez.
- O bot confirmava um agendamento sem ter todos os dados. Dizia “pronto, agendado!” mesmo faltando o telefone ou a data.
As duas têm a mesma causa, e a solução não é um prompt melhor.
A causa: o estado vive no histórico, não em dados
O jeito rápido de montar um agente no n8n é o nó AI Agent com a memória nativa. Essa memória guarda as últimas mensagens da conversa e as passa ao modelo a cada turno.
O problema é que ali o nome e o email não são dados: são frases soltas dentro de um histórico. O modelo precisa encontrá-los de novo a cada turno, e quando a conversa cresce e a janela de memória é cortada, as primeiras mensagens — justamente onde o cliente disse o nome — somem.
Com a segunda falha acontece algo parecido: se a regra “não confirme sem todos os dados” está só no prompt, o modelo a cumpre quase sempre. Num sistema que agenda visitas reais, “quase sempre” não serve.
A solução em quatro peças
1. O estado da conversa no Postgres
Cada conversa é uma linha numa tabela sessions, com os dados já confirmados numa coluna slots (JSONB) e o histórico curto à parte:
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)
);
O workflow carrega a sessão ao receber a mensagem e a salva no final com um 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();
Em historial guardo só os últimos 10–12 turnos: o que importa já não depende dele.
2. Dizer ao modelo, explicitamente, o que ele já sabe
Antes de chamar o modelo, um nó Code transforma os slots em texto e o coloca no prompt. Assim o nome não está “em alguma mensagem anterior”, e sim na instrução deste turno:
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 } }];
A lista de campos obrigatórios vem da configuração de cada cliente, não do código: cada imobiliária pode pedir dados diferentes.
3. Resposta em JSON, não em texto livre
No final do prompt, o modelo recebe um formato obrigatório. Além da mensagem para o cliente, ele devolve os dados novos que extraiu e a ação que propõe:
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
}
Com isso, os dados do cliente deixam de ser “texto que o modelo lembra” e viram campos que o workflow salva em slots.
4. A regra importante, em código
O último passo valida a saída do modelo antes de fazer qualquer coisa. Se o modelo quer agendar e falta algum dado, o código impede, não importa o que diga o prompt:
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 } }];
Se o JSON não puder ser lido, a conversa passa para um humano em vez de improvisar.
Como testar
O teste que uso é exatamente o caso que falhava:
- Informar nome e email na primeira mensagem.
- Falar de outra coisa por vários turnos (preço, localização, fotos).
- Pedir uma visita.
O bot não deve pedir de novo nem o nome nem o email, e não deve confirmar o agendamento até ter todos os campos obrigatórios. Se falhar, a tabela sessions mostra exatamente o que estava salvo naquele momento, algo que a memória nativa não deixa ver.
O que eu levo disso
- O estado de uma conversa é um dado, não uma lembrança do modelo. Vai num banco de dados que você pode consultar.
- Regras de negócio que não podem ser quebradas vão em código. O prompt serve para o tom e para orientar, não para garantir.
- O modelo propõe, o workflow decide. Pedir JSON e validá-lo transforma uma conversa livre num processo controlável.
Esse padrão é a base da recepcionista com IA que estou validando com imobiliárias no Brasil. Se você está montando algo parecido no n8n e travou, fale comigo.