Módulo 8: Proyecto: sistema de atención al cliente multicanal
5. Memoria persistente por cliente + web y WhatsApp
Descripción
Al terminar esta lección el sistema va a atender por dos canales reales con un solo cerebro y una sola memoria. Vas a convertir el workflow de las lecciones 3 y 4 en wf_agent_core con su Execute Sub-workflow Trigger y sus ocho campos declarados; vas a conectar la memoria con la clave que agrupa por cliente y no por canal; vas a poblar la tabla de identidades que hace que WhatsApp y la web sean la misma conversación; y vas a montar dos adaptadores delgados que no contienen ni una línea de lógica de negocio.
Y vas a cerrar algo que quedó abierto en la lección 4. Todos los filtros de customer_id que montaste ahí —los que hacen que un cliente no vea los pedidos de otro— dependen de que ese valor sea una identidad verificada. Hasta hoy era un texto que escribías a mano en un panel de pruebas. Al final de esta lección va a salir de una tabla, con registro de cómo se estableció. Esa diferencia es la que convierte la matriz de permisos de una intención en una garantía.
Esto importa porque es la lección que produce el momento más vistoso del proyecto y también el más peligroso. El vistoso: abrir dos ventanas —un chat web y un teléfono— hablar por una, seguir por la otra, y mostrar en la traza que fue el mismo agente con la misma memoria. El peligroso: si la resolución de identidad se equivoca, un cliente lee la conversación de otro, y eso no produce ningún error visible. Las dos cosas viven en la misma decisión.
Conexión con el módulo: la lección 2 decidió la clave de memoria, la política de identidad y el contrato de ocho campos; esta lo ejecuta. Las lecciones 3 y 4 construyeron el cerebro y las manos sobre un Chat Trigger temporal que hoy desaparece. Y todo lo que hagas hoy es prerrequisito de la lección 6: el mensaje de aprobación humana va a mostrar con qué medio se verificó la identidad del cliente, y ese dato nace aquí.
El hotel que te reconoce
En un hotel bien llevado, el huésped es una sola persona sin importar por dónde aparezca.
Llamas desde la habitación pidiendo toallas: en la centralita ven quién eres antes de que lo digas. Bajas al lobby y pides una reserva para cenar: el conserje ya sabe que llegaste hoy y que tienes desayuno incluido. Escribes por la aplicación preguntando a qué hora es la salida: la respuesta llega con tu nombre. Tres puertas, un huésped.
Lo que hace que eso funcione no es que las tres personas se conozcan entre sí. Es que las tres consultan la misma ficha, y esa ficha está atada a un identificador —tu número de habitación— que cada puerta sabe resolver a su manera: la centralita por la extensión desde la que llamas, el conserje porque te reconoce o te pregunta, la aplicación porque iniciaste sesión.
Ahora fíjate en dos detalles que un hotel resuelve y que tu sistema también tiene que resolver.
No todas las puertas dan la misma certeza. La extensión de la habitación es fuerte: solo puede llamar desde ahí quien está ahí. El conserje que te reconoce de vista es razonable. Alguien que se acerca al mostrador y dice "soy de la 402" es débil. Y por eso un hotel serio te da toallas si dices que eres de la 402, y no te da una llave nueva ni te carga una cena a la habitación sin verificar. La identidad para recordar y la identidad para actuar no son la misma cosa, y el umbral de la segunda es mucho más alto.
Y hay huéspedes sin ficha. Alguien entra de la calle a preguntar si hay habitaciones. No es nadie todavía, y eso es un caso legítimo, no un error. El hotel lo atiende igual, sin inventarle un número de habitación. Tu sistema tiene visitantes anónimos en el chat web, y un contrato que no los admite no es un contrato: es una aspiración.
Con eso en la cabeza, a construir el hotel.
Fase 1 — El núcleo
El workflow que vienes construyendo pasa a ser wf_agent_core. Solo cambian sus extremos: la entrada y la salida.
Paso 1.1 — Cambiar el trigger
Borra el Chat Trigger y pon un Execute Sub-workflow Trigger en su lugar. Renómbralo core_input.
Qué es este nodo. Es un disparador que no escucha a internet: solo se activa cuando otro workflow lo llama. En la lista de nodos aparece con una etiqueta del estilo "cuando lo ejecuta otro workflow". Su parámetro clave es cómo declara los datos que espera: puede aceptar cualquier cosa que le manden, o declarar campos con nombre y tipo. Declara los campos. Cuesta un minuto y convierte un contrato escrito en un papel en un contrato que n8n verifica por ti.
# Nodo: Execute Sub-workflow Trigger — Name: core_input
# Input data mode: definir campos abajo
# (confirma la etiqueta exacta en tu versión: la opción que
# quieres es la que te deja nombrar campos, no la que acepta
# cualquier cosa)
channel string
channel_user_id string
customer_id string
display_name string
text string
locale string
message_id string
verified_by string ← el octavo, nuevo en este proyecto
Paso 1.2 — Conectar el agente al campo correcto
El triage_agent leía chatInput del Chat Trigger. Ahora lee text:
# Nodo: AI Agent — Name: triage_agent
Source for Prompt (User Message): Define below
Prompt (User Message):
{{ $json.text }}
---
Contexto del sistema (no lo escribió el cliente):
cliente: {{ $json.display_name || 'desconocido' }}
customer_id: {{ $json.customer_id || 'no identificado' }}
identidad verificada por: {{ $json.verified_by || 'ninguna' }}
canal: {{ $json.channel }}
Ese bloque de contexto al final es lo que permite al agente saludar por nombre y saber si tiene identidad, sin que el system prompt cambie por canal. Y lleva una marca explícita —"no lo escribió el cliente"— porque un mensaje de cliente que contenga algo parecido a ese bloque es exactamente el ataque que el Módulo 7 llama injection disfrazado de sistema. La marca no lo impide; ayuda.
Y al System Message del triage_agent se le agrega un solo bloque, el de verbosidad:
# Agregado al System Message del triage_agent
El campo `canal` del contexto indica por dónde llegó el mensaje.
Úsalo SOLO para decidir cuánto texto escribir:
- web: hasta tres párrafos.
- whatsapp: máximo cuatro líneas, un tema por mensaje.
Escribe siempre Markdown estándar; la conversión de formato la
hace el sistema, no tú.
Escribe los valores idénticos a los que produce tu contrato. Si el contrato dice whatsapp y el prompt dice WhatsApp, algunos modelos lo resuelven y otros no — y ese "otros no" produce respuestas de tres párrafos en un teléfono sin que nada falle.
Y ojo con lo que ese bloque no dice: no dice nada de formato, ni de botones, ni de emojis. Es la excepción acotada a la separación de capas, y está acotada a la longitud a propósito. La conversión de Markdown a la marca de WhatsApp vive en el adaptador, y si algún día ves la palabra whatsapp en el núcleo fuera de este bloque, algo se coló en la capa equivocada.
Paso 1.3 — Componer la salida
El último nodo del núcleo arma el contrato de salida:
# Nodo: Set — Name: core_output
text = {{ $json.output }}
status = "resolved"
needs_human = false
quick_replies = []
attachments = []
session_key = {{ $('core_input').item.json.customer_id
? 'customer:' + $('core_input').item.json.customer_id
: $('core_input').item.json.channel + ':' +
$('core_input').item.json.channel_user_id }}
status y needs_human quedan fijos por ahora. En la lección 7 los va a producir el propio agente con su salida estructurada, que es la versión correcta; empezar con valores fijos y evolucionar a eso cuando el resto funcione es el orden que menos problemas da.
Fase 2 — La memoria y la clave
Aquí está la decisión de la lección 2, hecha expresión.
# Nodo: Postgres Chat Memory
Session ID: Define below
Key: {{ $json.customer_id
? 'customer:' + $json.customer_id
: $json.channel + ':' + $json.channel_user_id }}
Léela en voz alta, porque dice exactamente lo que decidiste: si se conoce al cliente, la conversación es del cliente y se comparte entre canales; si no, es del canal, con su prefijo para que dos identificadores numéricos de canales distintos nunca colisionen.
Ese prefijo no es decorativo. Sin él, un sessionId del chat web que casualmente coincidiera con un número de teléfono produciría dos personas compartiendo memoria. Es improbable y es gratis prevenirlo.
Un detalle de configuración que conviene verificar: el nodo de memoria tiene una opción de cuántos mensajes conserva en la ventana de contexto. El valor por defecto suele ser conservador. Para atención al cliente, una ventana de diez a veinte intercambios es un buen punto de partida: suficiente para que el cliente no repita datos, y no tanto como para que el contexto se llene de conversaciones viejas. Confirma la etiqueta exacta en el panel de tu versión y anota el valor que elegiste — en la lección 7 va a aparecer en la hoja de costo, porque cada mensaje de la ventana se reenvía en cada iteración.
Fase 3 — La identidad
Esta es la fase que hace que el proyecto valga la pena, y es la que casi nadie hace.
Paso 3.1 — La tabla
-- Ata cada identidad de canal con el cliente real de TuTienda.
CREATE TABLE IF NOT EXISTS channel_identities (
channel TEXT NOT NULL, -- 'web' | 'whatsapp'
channel_user_id TEXT NOT NULL, -- sessionId o teléfono
customer_id TEXT NOT NULL, -- 'C-9931'
verified_by TEXT NOT NULL, -- 'session'|'crm_phone'|'declared'
verified_at TIMESTAMPTZ NOT NULL DEFAULT now(),
PRIMARY KEY (channel, channel_user_id)
);
-- Dos identidades del MISMO cliente. Esto es lo que va a hacer
-- que el caso del cambio de canal funcione.
INSERT INTO channel_identities
(channel, channel_user_id, customer_id, verified_by)
VALUES
('whatsapp', '5215512345678', 'C-9931', 'crm_phone'),
('web', 'C-9931', 'C-9931', 'session')
ON CONFLICT DO NOTHING;
La columna verified_by no es decorativa: registra cómo se estableció esa identidad, que es lo que permite decidir después si alcanza para una acción sensible. session es fuerte —venía de una sesión autenticada—, crm_phone es razonable —el teléfono está en el CRM—, declared es débil —el cliente lo dijo en el chat—.
La analogía del hotel se aplica literal: session es la extensión de la habitación, crm_phone es el conserje que te reconoce, declared es alguien diciendo "soy de la 402".
Y esa credencial también hay que darla:
GRANT SELECT ON channel_identities TO n8n_agent_ro;
-- El agente NO escribe en esta tabla. La escribe tu aplicación
-- cuando alguien inicia sesión, o un proceso de alta cuando un
-- cliente se registra. Si el agente pudiera escribirla, podría
-- otorgarse la identidad que quisiera.
Ese comentario merece leerse dos veces. Es la tabla que decide quién es cada quien; si el agente pudiera escribirla, todas las palancas de la lección 4 se vuelven decorativas.
Paso 3.2 — La resolución, en cada adaptador
Dos nodos entre la normalización y la llamada al núcleo:
# Nodo: Postgres — Name: resolve_customer
# Operation: Execute Query (consulta fija, parámetros como
# parámetros — igual que la KB)
SELECT customer_id, verified_by
FROM channel_identities
WHERE channel = $1 AND channel_user_id = $2;
# Query Parameters:
# {{ $json.channel }}
# {{ $json.channel_user_id }}
#
# Si no hay fila, el resultado viene vacío. Eso NO es un error:
# es un cliente todavía no identificado, que es un caso válido.
# Nodo: Set — Name: merge_identity
# Si hubo fila, usa ese customer_id. Si no, deja el que ya venía
# (en la web puede venir de metadata) o vacío.
customer_id = {{ $json.customer_id
|| $('normalize_incoming').item.json.customer_id
|| "" }}
verified_by = {{ $json.verified_by
|| ($('normalize_incoming').item.json.customer_id
? 'session' : '') }}
Qué esperar. Escribe por WhatsApp desde el número registrado y mira la salida de resolve_customer: una fila, con customer_id: "C-9931" y verified_by: "crm_phone". Escribe desde un número que no esté en la tabla y la salida viene vacía, sin error — y el núcleo va a recibir customer_id vacío, que es exactamente lo que el contrato admite.
Fase 4 — El adaptador web
Cinco nodos y ninguna sorpresa.
# Nodo: Chat Trigger — Name: web_chat_in
Mode: Embedded Chat
Response Mode: When Last Node Finishes
Authentication: None
Allowed Origin (CORS): https://tutienda.example, http://localhost:8080
Load Previous Session: Memory Connected to Agent
# Nodo: Set — Name: normalize_incoming
channel = "web"
channel_user_id = {{ $json.sessionId }}
customer_id = {{ $json.metadata?.customer_id || "" }}
display_name = {{ $json.metadata?.display_name || "" }}
text = {{ $json.chatInput }}
locale = "es-MX"
message_id = {{ $json.sessionId + '-' + $now.toMillis() }}
verified_by = ""
Confirma la ruta hasta metadata en una ejecución real de tu versión: es de las cosas que cambian y produce un customer_id vacío sin ningún error visible.
Fíjate en algo que este nodo hace y que es todo el trabajo de un adaptador: traduce los nombres del canal a los nombres del contrato. chatInput se vuelve text, sessionId se vuelve channel_user_id. A partir de aquí el núcleo no sabe que existe un Chat Trigger.
La llamada al núcleo:
# Nodo: Execute Sub-workflow — Name: call_core
Workflow: wf_agent_core
Wait For Sub-Workflow Completion: ACTIVADO
← para una conversación, siempre. Si esto queda apagado, el
canal sigue de largo y responde con datos vacíos, y las dos
ejecuciones aparecen exitosas en n8n.
Workflow Inputs: los ocho campos, uno a uno.
Los nombres exactos de estos parámetros cambian entre versiones. Abre los dos nodos en tu instalación y confirma las etiquetas antes de dar por buena la configuración.
Y la página de prueba, que puede ser un archivo HTML local servido en localhost:8080:
<!-- Página de prueba del chat de TuTienda -->
<link href="https://cdn.jsdelivr.net/npm/@n8n/chat/dist/style.css" rel="stylesheet" />
<script type="module">
import { createChat } from 'https://cdn.jsdelivr.net/npm/@n8n/chat/dist/chat.bundle.es.js';
createChat({
webhookUrl: 'https://TU-INSTANCIA-N8N/webhook/xxxxxxxx/chat',
mode: 'window',
// En producción, tu servidor rellena esto desde la sesión
// autenticada. Para probar, se pone a mano.
metadata: {
customer_id: 'C-9931',
display_name: 'Ana'
},
initialMessages: [
'¡Hola! Soy el asistente de TuTienda.',
'Puedo ayudarte con pedidos, devoluciones y cobros.'
]
});
</script>
Guarda dos versiones de esa página: una con metadata (cliente identificado) y otra sin (visitante anónimo). Las vas a necesitar en las pruebas, y tenerlas listas ahorra ediciones a mitad de la corrida.
Fase 5 — El adaptador de WhatsApp, con honestidad de costo
Antes de los nodos, la decisión práctica, porque este canal cuesta dinero en producción y exige que Meta verifique tu negocio.
Ruta A — El número de prueba de Meta (recomendada). Creas una cuenta de desarrollador, una app, agregas el producto de WhatsApp, y Meta te da un número de prueba gratuito con el que puedes conversar con una lista corta de destinatarios que tú registras (típicamente cinco). No necesitas verificar el negocio ni comprar nada, y todos los mensajes que vas a mandar son respuestas dentro de la ventana de servicio de 24 horas. Tiempo del trámite: de media hora a un par de horas, según lo que tarde cada pantalla del panel de Meta.
Ruta B — Telegram como sustituto. Si el trámite se atasca, monta el segundo canal con Telegram. Todo lo estructural es idéntico: el núcleo, el contrato, la identidad compartida, la adaptación de salida y los casos de prueba. Lo único que pierdes es la experiencia de la ventana de 24 horas, que ya entendiste conceptualmente en el Módulo 6.
Y la honestidad de costo, que es parte del entregable. Cuando este sistema atienda clientes de verdad, WhatsApp Business API cobra por conversación —no por mensaje suelto— con precios que varían por país y por categoría de conversación, y que Meta ajusta periódicamente. Las conversaciones que inicia el cliente y se responden dentro de la ventana de servicio de 24 horas tienen un tratamiento distinto de las que inicia la empresa con una plantilla aprobada, que son las caras. Para un sistema de atención al cliente como este, la enorme mayoría del tráfico es del primer tipo, que es la buena noticia. Los números concretos los tienes que mirar en la tabla de precios vigente de Meta para tu país el día que lo pongas en producción — cualquier cifra que yo escriba aquí va a estar vieja. Lo que sí puedes escribir hoy en tu documento es la estructura: costo por conversación de WhatsApp, más costo de tokens por conversación, y en la lección 7 vas a medir el segundo.
Los nodos:
# Credenciales: son DOS y es fácil confundirlas.
# WhatsApp API (Access Token + Business Account ID) → envío
# WhatsApp OAuth2 (App ID + App Secret) → trigger
# Si envía pero no recibe, o al revés, es una de las dos.
# Nodo: IF — Name: is_text_message
Condición 1: {{ $json.entry[0].changes[0].value.messages }} → existe
Condición 2: {{ $json.entry[0].changes[0].value.messages[0].type }}
→ igual a "text"
Combinar: AND
# La rama falsa termina en un NoOp. WhatsApp manda eventos de
# estado (entregado, leído) por el mismo webhook, y responder a
# esos produce un bucle. Nunca respondas por ahí.
# Nodo: Set — Name: normalize_incoming
channel = "whatsapp"
channel_user_id = {{ $json.entry[0].changes[0].value.messages[0].from }}
customer_id = ""
display_name = {{ $json.entry[0].changes[0].value.contacts[0].profile.name }}
text = {{ $json.entry[0].changes[0].value.messages[0].text.body }}
locale = "es-MX"
message_id = {{ $json.entry[0].changes[0].value.messages[0].id }}
verified_by = ""
# Nodo: Code — Name: format_for_whatsapp
// El agente escribe Markdown estándar. WhatsApp usa su propia
// marca y corta los mensajes largos.
let text = $json.text;
text = text.replace(/\*\*(.+?)\*\*/g, '*$1*'); // **negrita** → *negrita*
text = text.replace(/^#{1,6}\s+/gm, ''); // fuera los títulos
text = text.replace(/^[\-\*]\s+/gm, '• '); // viñetas → punto medio
const MAX = 4000; // margen bajo el límite
const chunks = [];
let current = '';
for (const p of text.split('\n\n')) {
if ((current + '\n\n' + p).length > MAX && current) {
chunks.push(current); current = p;
} else {
current = current ? current + '\n\n' + p : p;
}
}
if (current) chunks.push(current);
// Un item por trozo: el nodo de envío los manda en orden.
return chunks.map(c => ({ json: { text: c } }));
# Nodo: WhatsApp Business Cloud — Name: send_whatsapp_reply
Resource: Message · Operation: Send
Phone Number ID: <el ID de TU número de negocio>
Recipient Phone Number: {{ $('normalize_incoming').item.json.channel_user_id }}
Message Type: Text
Text Body: {{ $json.text }}
Recuerda registrar tu propio teléfono como destinatario de prueba en el panel de Meta antes de intentar cualquier cosa. Sin eso, el envío falla con un error que no dice claramente que el problema es ese.
Y el adaptador web necesita su nodo espejo, aunque no haga nada:
# Nodo: Code — Name: format_for_web
// El widget interpreta Markdown, así que no hay que convertir
// nada. Este nodo existe por simetría: el día que haya que
// adaptar algo, ya hay dónde ponerlo.
return [{ json: { text: $json.text } }];
Parece inútil y no lo es. Tener el mismo esqueleto en los dos adaptadores hace que agregar el tercer canal sea copiar un patrón conocido en vez de inventar uno.
Fase 6 — Verificar el grafo
Un minuto que evita problemas difíciles de diagnosticar. Siete puntos:
1. Hay exactamente UN nodo AI Agent de tipo raíz en toda la
solución, y está en wf_agent_core.
2. Hay exactamente UN nodo de memoria, y está en wf_agent_core.
3. Ningún workflow de canal contiene un AI Agent, ni una tool,
ni una regla de negocio, ni un plazo, ni un monto.
4. wf_agent_core NO contiene la palabra "whatsapp" fuera del
bloque de verbosidad del system prompt.
5. Los dos nodos Execute Sub-workflow tienen ACTIVADA la opción
de esperar la finalización.
6. Los dos nodos normalize_incoming producen EXACTAMENTE los
mismos ocho nombres de campo.
7. Las expresiones de customer_id en las tools de la lección 4
ahora leen de core_input, no del Chat Trigger que ya no existe.
El punto 6 se verifica abriendo los dos nodos lado a lado. Si un nombre difiere en una letra, el núcleo recibe un campo vacío y no falla: simplemente se comporta como si el dato no existiera, que es la clase de error más cara de encontrar.
El punto 7 es el que más se olvida en este proyecto concreto, porque las tools se montaron cuando el trigger era otro. Si lookup_order sigue apuntando a un nodo que ya no existe, la expresión devuelve vacío y el filtro de customer_id deja de filtrar. Es un fallo silencioso con consecuencias de privacidad, así que revísalo tool por tool.
Fase 7 — Los cuatro casos que verifican esta lección
De los doce de la batería, estos cuatro son los que dependen de lo que construiste hoy.
C1 y C2 — La misma pregunta por los dos canales.
Abre la página con metadata y escribe "Hola, ¿cómo va mi pedido #4521?". Después escribe lo mismo desde tu teléfono registrado.
Esperado: la misma información, y la respuesta de WhatsApp notablemente más corta — máximo cuatro líneas. Compáralas lado a lado: es la prueba visible de que la variable de canal funciona. Si salen iguales de largas, el bloque de verbosidad no está tomando, y el sospechoso es casi siempre que el valor de channel no coincide letra por letra con el que dice el prompt.
C6 — Visitante anónimo.
Abre la página sin metadata y escribe "¿cómo va mi pedido?".
Esperado: el agente pide el correo o el número de pedido antes de consultar nada. Si responde con datos de algún cliente, tienes un problema serio y hay que corregirlo antes de seguir. Verifica también en la traza que lookup_order no se llamó: con customer_id vacío, el filtro de la tool no filtraría nada, así que la defensa aquí es que el agente ni siquiera lo intente.
C7 — El cliente que cambia de canal. Este es el caso central.
Escribe por WhatsApp desde el número registrado: "Hola, quiero devolver los audífonos que compré el mes pasado." Deja que el agente responda. Después abre la página web con metadata de C-9931 y escribe: "¿y cuánto tarda el reembolso de eso?"
Esperado: el agente sabe de qué habla "eso". No pregunta qué audífonos ni qué devolución.
Qué esperar en la traza de C7, que es el entregable visual del proyecto:
Ejecución A — wf_channel_whatsapp
normalize_incoming → channel_user_id: "5215512345678"
resolve_customer → customer_id "C-9931", verified_by "crm_phone"
call_core → sub-ejecución B
format_for_whatsapp → 1 trozo, 3 líneas
send → entregado
Ejecución B — wf_agent_core
memoria: session_key "customer:C-9931" ← la clave
triage_agent → order_specialist
→ lookup_order + check_return_eligibility
core_output → status resolved
Ejecución C — wf_channel_web (unos minutos después)
normalize_incoming → channel_user_id "sess-abc",
customer_id desde metadata "C-9931"
resolve_customer → customer_id "C-9931", verified_by "session"
call_core → sub-ejecución D
Ejecución D — wf_agent_core
memoria: session_key "customer:C-9931" ← LA MISMA
el historial cargado incluye el turno de WhatsApp
triage_agent responde sin volver a preguntar
Esa línea repetida —la misma session_key desde dos canales distintos— es literalmente el entregable de esta lección. Es lo que se señala en la demo de la lección 8, y es lo que hace que el proyecto no sea "dos chatbots".
Si el agente pregunta de qué se trata, revisa en este orden: que la tabla tenga las dos filas, que resolve_customer esté devolviendo el customer_id en los dos canales, y que la clave de memoria esté usando el prefijo customer: cuando hay customer_id.
Cuando la identidad se equivoca
La lección tiene un momento incómodo y conviene mirarlo de frente, porque es lo que separa un proyecto que se defiende de uno que se cae en la primera pregunta difícil.
Unificar la conversación por cliente tiene un modo de fallo que no produce ningún error: si la resolución se equivoca, un cliente lee la conversación de otro. No hay nodo rojo, no hay alerta, la ejecución sale en verde. Y estos son los tres caminos por los que pasa de verdad:
El teléfono compartido. Una familia con un teléfono, dos personas que compran en TuTienda. Los dos escriben desde el mismo número. Tu tabla ata ese número a un solo customer_id, así que uno de los dos ve el historial y los pedidos del otro. Es común y la corrección no es técnica: el agente debe pedir un dato de desambiguación cuando la consulta no corresponde a los pedidos del cliente resuelto, en vez de asumir.
El teléfono reasignado. Alguien cambia de número y meses después una operadora se lo asigna a otra persona, que escribe a TuTienda por primera vez y hereda una identidad. Es raro y es real. La corrección: caducar las identidades con verified_by = 'crm_phone' después de cierto tiempo sin actividad, y volver a verificar.
La sesión web compartida. Una computadora de oficina o de un café donde alguien no cerró sesión. El siguiente que abre el chat va con el customer_id del anterior. Aquí la responsabilidad es de tu aplicación web, no del agente — pero conviene saber que el agente hereda la confianza de la sesión que lo alimenta, ni más ni menos.
Ninguno de los tres se resuelve del todo en un proyecto de esta escala, y esa es la respuesta honesta. Lo que sí se hace, y es lo que convierte un hueco en una limitación documentada, son dos cosas.
La primera: la política escrita, que ya decidiste en la lección 2 y que ahora tiene una tabla que la soporta. Para recordar y personalizar, cualquier identidad sirve. Para leer datos del cliente, hace falta session o crm_phone. Para acciones sensibles, no basta ninguna: hace falta una persona, y esa persona va a ver el verified_by en el mensaje de aprobación de la lección 6.
La segunda: que el agente lo sepa. Una línea en el system prompt que cierra el caso del teléfono compartido:
# Agregado al System Message del triage_agent
Si el cliente menciona un pedido, un cargo o un dato que no
aparece asociado a su cuenta, NO asumas que se equivocó de
número ni que el sistema falló. Puede tratarse de otra persona
usando el mismo dispositivo. Dile que ese dato no aparece en su
cuenta y pídele que confirme el correo con el que compró.
Esa línea no arregla el problema. Convierte un fallo silencioso —el sistema atendiendo a la persona equivocada— en una pregunta al cliente, que es un resultado mucho mejor y cuesta cuatro líneas.
Errores comunes
Construir los canales antes que el núcleo (práctico). Qué pasa: alguien empieza por el adaptador de WhatsApp porque es lo visible, y cuando algo falla hay cinco sospechosos: las credenciales de Meta, el filtro de eventos, la normalización, el contrato y el prompt. Por qué pasa: el canal es la parte que se ve y da sensación de avance. Cómo detectarlo: si llevas media hora cambiando cosas sin poder decir qué cambió qué, es esto. Cómo corregirlo: la fase 1 completa —el núcleo ejecutado a mano desde el editor con un payload fijo de ocho campos— antes de tocar ningún trigger. Con un cerebro probado, cada canal tiene un solo sospechoso nuevo.
Probar el caso del cambio de canal sin haber poblado la tabla (práctico). Qué pasa: se corre C7, el agente no recuerda nada, y se empieza a revisar la configuración de memoria, la clave de sesión y el nodo de Postgres. Todo está bien: lo que falta son las dos filas. Por qué pasa: el INSERT son tres líneas en medio de una fase llena de nodos y es facilísimo saltárselo. Cómo detectarlo: consulta la tabla antes de acusar a nada más. Cómo corregirlo: correr el INSERT, y verificar que el channel_user_id de la fila de WhatsApp coincide exactamente con el que produce tu adaptador — con el formato del número tal como lo manda Meta, sin + y sin espacios. Es la diferencia entre que funcione y que no, y no da ninguna pista.
Olvidar activar la espera del sub-workflow (práctico). Qué pasa: el canal llama al núcleo y sigue de largo. El nodo de envío se ejecuta con datos vacíos, el cliente recibe un mensaje en blanco, y en n8n las dos ejecuciones aparecen exitosas. Por qué pasa: la opción existe porque hay casos donde no quieres esperar, y su valor por defecto puede no ser el que necesitas. Cómo detectarlo: si el mensaje sale vacío pero la ejecución del núcleo se ve correcta y con su respuesta, es esto. Cómo corregirlo: activa la opción de esperar la finalización. Para una conversación, siempre.
Formatear en el núcleo "porque es más cómodo" (conceptual). Qué pasa: alguien mete la conversión de asteriscos dentro de wf_agent_core con un Switch por canal, porque así está todo en un lugar. El núcleo vuelve a saber de WhatsApp y crece con cada canal nuevo. Por qué pasa: centralizar se siente ordenado. Cómo detectarlo: el punto 4 de la verificación del grafo existe exactamente para esto. Cómo corregirlo: el formateo vive en el adaptador de salida. La prueba mental es simple: si mañana agregas un canal, ¿tienes que abrir el núcleo? Si la respuesta es sí, algo está en la capa equivocada.
Dejar las tools apuntando al trigger viejo (práctico). Qué pasa: lookup_order sigue con {{ $('Chat Trigger').item.json.customer_id }} en su condición WHERE, y ese nodo ya no existe. La expresión devuelve vacío, el filtro no filtra, y la consulta pasa a devolver los cinco primeros pedidos de la tabla sin importar de quién sean. La respuesta al cliente se ve perfectamente normal. Por qué pasa: cambiar el trigger es un solo nodo y no es evidente que haya cinco expresiones apuntando a él. Cómo detectarlo: busca Chat Trigger en el JSON exportado del núcleo; el número correcto de apariciones es cero. Cómo corregirlo: el punto 7 de la verificación del grafo, tool por tool, y una prueba concreta — pide un pedido que no sea del cliente identificado y confirma que devuelve cero filas.
Ejercicios
Ejercicio 1 — Agrega el tercer canal. Con el sistema funcionando, agrega Telegram como tercer adaptador. Cronometra cuánto te toma desde que abres n8n hasta que C1 pasa por Telegram. Después responde: ¿qué tuviste que tocar de wf_agent_core?
Ver solución
El tiempo típico está entre veinte y cuarenta minutos, y la mayor parte se va en el trigger, la credencial y las expresiones del payload de Telegram — no en el agente.
La respuesta a la segunda pregunta debería ser casi nada, y es el punto entero del ejercicio:
- El bloque de verbosidad del system prompt gana una línea:
- telegram: igual que whatsapp. - Nada más. Ni una tool, ni un especialista, ni una regla, ni la memoria.
Si tuviste que tocar algo más, mira qué fue: es señal de que ese algo estaba en la capa equivocada desde antes y el tercer canal lo puso en evidencia. Los dos culpables más frecuentes: alguna conversión de formato que se coló en el núcleo, y algún nombre de campo que un adaptador producía distinto y que el núcleo estaba tolerando con un respaldo encadenado.
Ese es exactamente el valor de agregar un tercer canal aunque no lo necesites: el tercero es el que audita la arquitectura. Con dos, muchas impurezas pasan desapercibidas.
Y un detalle que aparece y conviene resolver bien: Telegram manda botones como callback_query, no como texto. La regla del contrato es que text siempre es lenguaje, así que el adaptador traduce order:4521 a "Consultar el estado del pedido 4521." antes de llamar al núcleo. El núcleo nunca se entera de que existió un botón.
Por qué funciona: el ejercicio convierte "la arquitectura es buena" en un número de minutos y una lista de cosas tocadas, que es un argumento mucho más fuerte que una opinión — y es exactamente la clase de dato que se cita en una entrevista.
Ejercicio 2 — Rompe la identidad a propósito. Monta el escenario del teléfono compartido: agrega una segunda fila a channel_identities que ate el mismo channel_user_id de WhatsApp a un cliente distinto (vas a necesitar quitar temporalmente la clave primaria, o usar otro número). Escribe desde ese número preguntando por un pedido del otro cliente y documenta qué pasa exactamente, capa por capa.
Ver solución
Lo que se observa, si tu sistema está bien montado:
En resolve_customer. Con la clave primaria quitada, la consulta devuelve dos filas. El nodo merge_identity toma la primera —que es un orden arbitrario, y esa arbitrariedad ya es un hallazgo—. El sistema resolvió una identidad sin ninguna base para elegir esa y no la otra.
En la tool. El cliente pregunta por el pedido 4498, que es del otro customer_id. lookup_order filtra por el customer_id que se resolvió y devuelve cero filas. Aquí está la buena noticia del ejercicio: la palanca 3 de la lección 4 funciona, y funciona precisamente en el caso donde la identidad falló. Dos capas independientes, y la segunda cubrió el fallo de la primera.
En la respuesta. Aquí es donde se ve si tu prompt está completo. Sin la línea que agregaste en esta lección, el agente suele responder "no encuentro ese pedido, ¿estás seguro del número?" — que le echa la culpa al cliente por un problema del sistema. Con la línea, responde que ese pedido no aparece en su cuenta y pide confirmar el correo de compra, que es honesto y accionable.
Y el hallazgo que no se ve en ninguna capa: la memoria. Los dos usuarios del teléfono comparten channel_user_id, así que si la resolución los manda al mismo customer_id, comparten session_key y por lo tanto comparten historial. La tool los protegió de ver los pedidos del otro; la memoria no los protege de leer lo que el otro escribió. Es el hueco real de este escenario, y la mitigación proporcionada a la escala del proyecto es documentarlo — con la nota de que la solución de producto es pedir identificación explícita en el primer turno de cada conversación de WhatsApp, con el costo de fricción que eso tiene.
Restaura la clave primaria al terminar. Y anota el hallazgo en tu documento: un sistema donde puedes decir "esto falla en este caso, lo mitigué así, y este otro caso lo dejé abierto a propósito porque el costo de cubrirlo no se justifica todavía" se defiende infinitamente mejor que uno que nunca se cuestionó.
Por qué funciona: la identidad es donde un sistema multicanal se rompe de verdad, y es la parte que ningún tutorial cubre. Tener una falla documentada con su corrección y su límite es la mejor respuesta posible a "¿qué le hiciste para saber que funciona?".
Ejercicio 3 — Calcula el costo de WhatsApp para TuTienda. Con los precios vigentes de Meta para tu país, calcula cuánto costaría operar este sistema con 2,000 conversaciones al mes, y escribe el resultado como se lo dirías al dueño de la tienda. Distingue lo que depende del canal de lo que depende del modelo.
Ver solución
El número exacto depende de tu país y del mes en que lo mires, así que lo que importa es la estructura del cálculo y cómo se presenta.
La estructura, que sí es estable:
Costo mensual = (conversaciones × precio por conversación WhatsApp)
+ (conversaciones × tokens por conversación × precio
por token)
+ infraestructura (n8n self-hosted: $0 + el servidor)
Tres cosas que hay que decir sobre ese cálculo y que casi nadie dice:
Las conversaciones de WhatsApp se cuentan por ventana, no por mensaje. Un cliente que manda seis mensajes y recibe seis respuestas en una tarde es una conversación, no doce. Eso cambia el número por un factor grande, y confundirlo es el error de estimación más común.
El tráfico de este sistema es casi todo iniciado por el cliente. Es la categoría de servicio, que en la estructura de precios de Meta tiene un tratamiento distinto —y en varios países más favorable— que las conversaciones de marketing o de utilidad que inicia la empresa con una plantilla. Un sistema de atención al cliente está en el lado bueno de esa distinción, y vale la pena decirlo porque quien haya oído hablar de "lo caro que es WhatsApp" probablemente lo oyó de alguien que hacía campañas.
El canal web cuesta cero. Toda conversación que ocurre en el chat de tu sitio no paga canal, solo modelo. Eso convierte una decisión de producto —dónde poner el chat, qué tan visible— en una palanca de costo directa, y es un argumento que en una reunión vale más que cualquier optimización de prompt.
Cómo se lo dices al dueño, que es lo que el ejercicio pide:
"El costo tiene dos partes que se comportan distinto. WhatsApp cobra por conversación —no por mensaje— y solo cuando el cliente escribe primero, que es el 100% de nuestro caso; el precio lo fija Meta por país y conviene revisarlo cada trimestre porque lo ajustan. El modelo cobra por token, y ahí el número lo tengo medido sobre nuestro propio sistema: lo tienes en la hoja de costo. Con 2,000 conversaciones al mes, la parte del modelo es X y la de WhatsApp es Y. Y hay una palanca que no cuesta nada: cada conversación que ocurre en el chat de la web en vez de WhatsApp ahorra la parte del canal completa. Si el widget estuviera más visible en las páginas de seguimiento de pedido, una parte del tráfico se movería solo."
Por qué funciona: presentar el costo separando lo que depende del canal de lo que depende del modelo permite una conversación sobre palancas en vez de una sobre resignación. Y la última frase convierte un reporte de gastos en una propuesta, que es una diferencia de rol.
Resumen y siguiente paso
Ya tienes el sistema multicanal completo. El cerebro de las lecciones 3 y 4 vive en wf_agent_core, disparado por un Execute Sub-workflow Trigger que declara los ocho campos del contrato. Dos adaptadores delgados de cinco o seis nodos cada uno: reciben, normalizan al contrato, resuelven la identidad contra channel_identities, llaman al núcleo, formatean la salida según su canal y envían. La memoria vive en un solo lugar, agrupada por cliente cuando se conoce y por canal cuando no. Y las tools de la lección 4 ahora filtran por un customer_id que salió de una tabla, con registro de cómo se estableció.
Y tienes documentados los tres caminos por los que la identidad se equivoca en la vida real, con la mitigación de cada uno y el límite honesto de lo que este proyecto cubre.
Antes de avanzar deberías poder: dibujar de memoria los dos nodos que conectan un canal con el núcleo; explicar por qué el prefijo customer: existe en la clave de memoria; decir qué pasa, en cada capa, cuando customer_id viene vacío; y señalar en la traza de C7 la línea que demuestra que fue la misma conversación.
Lo que sigue son las cerraduras. La lección 6 pone el guardrail de entrada calibrado contra los casos legítimos y no contra los ataques, y monta la aprobación humana sobre issue_refund con el mensaje de cinco campos —incluido el verified_by que nace hoy—, su política de umbrales calculada contra la capacidad real del equipo, y la cláusula que impide que un rechazo se renegocie dentro de la conversación. Al final de esa lección, la línea incómoda de la tabla de la lección 4 —"sacar dinero, sin límite, sin verificación"— va a poder reescribirse.
Recursos
- Execute Sub-workflow Trigger — n8n Docs — el disparador del núcleo y la declaración de campos que convierte tu contrato en algo que n8n verifica.
- Execute Sub-workflow node — n8n Docs — el nodo que llama al núcleo desde cada canal; confirma ahí la opción de esperar la finalización en tu versión.
- Chat Trigger — n8n Docs — el modo embebido, el CORS y
Load Previous Sessiondel adaptador web. - @n8n/chat — npm — las opciones de
createChat, incluidametadata, que es la que hace posible el caso del cambio de canal. - WhatsApp Trigger — n8n Docs — los eventos del webhook y la advertencia sobre los eventos de estado que hay que filtrar.
- WhatsApp Business Cloud — n8n Docs — las operaciones de envío y los dos campos de número que es fácil confundir.
- Postgres Chat Memory — n8n Docs — el almacén compartido y el selector de clave de sesión donde vive la decisión de identidad.
- WhatsApp Business Platform — pricing — la tabla de precios vigente por país y categoría de conversación; revísala el día que pongas el sistema en producción, porque cambia.