Módulo 6: Canales reales: chat web, WhatsApp, Telegram y voz
7. Un agente, varios canales: arquitectura reutilizable
Descripción
Al terminar esta lección vas a poder separar el cerebro de tu agente de la capa de canal: mover el sistema del Módulo 5 a un sub-workflow reutilizable con Execute Sub-workflow Trigger, escribir el contrato de datos que entra y sale de ese núcleo, construir adaptadores delgados por canal que hablen ese contrato, y resolver la decisión de identidad que atraviesa todo el módulo —si el historial de conversación pertenece al canal o al cliente— con criterio y no por accidente. También vas a saber qué cuesta esta arquitectura y en qué casos no vale la pena.
Esto importa porque es lo que convierte cuatro demos en un sistema. Con lo que llevas del módulo puedes montar un agente en chat web, otro en WhatsApp, otro en Telegram y una tool de voz, y los cuatro funcionan. El problema aparece la primera vez que hay que cambiar algo: una regla de negocio nueva, una tool más, un ajuste al prompt del triage_agent. En cuatro copias, ese cambio son cuatro ediciones, cuatro rondas de prueba y una probabilidad muy alta de que una quede desactualizada sin que nadie se entere hasta que un cliente reciba una respuesta vieja. Es también, muy concretamente, lo que se ve en treinta segundos de demostración: un núcleo con tres adaptadores contra cuatro workflows casi iguales.
Conexión con el módulo: esta lección es la que junta todo. La lección 1 dibujó las tres capas; las lecciones 2 a 5 te dieron un canal cada una, y en las cuatro escribiste un nodo Set llamado normalize_incoming con exactamente los mismos nombres de campo — eso no fue casualidad, fue la semilla del contrato que vas a formalizar aquí. La lección 6 definió qué se adapta en cada canal. Y del Módulo 5 traes algo que se aplica casi literal: si el contrato entre un orquestador y un especialista hace que dos agentes se entiendan sin adivinar, un contrato entre el canal y el núcleo hace exactamente lo mismo entre dos capas.
La cocina central
Piensa en una cadena de comida que atiende de cuatro formas: gente que come en el local, gente que pide para llevar en el mostrador, repartidores de una aplicación, y pedidos por teléfono.
La versión ingenua es montar cuatro cocinas. Una para el salón, otra para llevar, otra para reparto, otra para teléfono. Funciona el primer día. Y a la semana el plato del salón lleva una salsa que el de reparto no, porque alguien cambió la receta en una cocina y no en las otras. Nadie tomó la decisión de tener dos recetas: simplemente pasó.
La versión que usa cualquier cadena real es una sola cocina y cuatro formas de entregar. La cocina no sabe ni le importa por dónde llegó el pedido: recibe una comanda con un formato fijo —qué plato, cuántos, qué modificaciones— y produce un plato. Lo que cambia es lo de afuera: el plato del salón va en loza y con guarnición montada; el de reparto va en un envase que aguante veinte minutos y sin la guarnición que se enfría; el de teléfono se confirma en voz antes de prepararse.
Fíjate en las dos piezas que hacen que eso funcione, porque son exactamente las dos que vas a construir.
La comanda. Un formato fijo que todas las entradas producen y que la cocina entiende. Quien toma el pedido por teléfono y quien lo recibe de la aplicación escriben la misma comanda, aunque el origen sea completamente distinto. Sin ese formato común, la cocina tendría que saber interpretar cuatro cosas distintas — y entonces la cocina volvería a saber de canales.
El montaje. Lo que pasa después de que el plato está hecho: emplatar, envasar, decidir qué acompaña. Es específico de cada salida y no cambia la receta.
Comanda y montaje. Adaptador de entrada y adaptador de salida. La receta —tu triage_agent con sus especialistas, sus tools y su memoria— vive en un solo lugar y no se entera de nada.
El costo real de las cuatro copias
Antes de construir, vale la pena poner números a lo que se evita, porque es un argumento que se usa en una entrevista y se usa también para convencer a un equipo.
Supón un sistema modesto: el triage_agent, dos especialistas, cuatro tools de dominio, un nodo de memoria. Son ocho nodos configurados, más los prompts. Ahora en cuatro canales:
Con copias Con núcleo + adaptadores
────────────────────────── ──────────────────────────
4 workflows × 8 nodos = 32 1 núcleo × 8 nodos = 8
4 copias del system prompt 1 system prompt
4 copias de cada Description 1 de cada Description
Cambiar una regla de negocio: Cambiar una regla de negocio:
4 ediciones + 4 pruebas 1 edición + 1 prueba
Agregar una tool: Agregar una tool:
4 veces, o queda inconsistente 1 vez
Agregar un canal: Agregar un canal:
copiar 8 nodos y adaptar 4 nodos de adaptador
El número que más duele no está en esa tabla, y es la probabilidad. Con cuatro copias, la probabilidad de que las cuatro estén sincronizadas después de tres meses de cambios es baja. Y el modo de fallo es silencioso: nadie ve un error, simplemente el cliente de WhatsApp recibe una política que se cambió hace seis semanas en la copia de la web.
La arquitectura, en nodos de n8n
n8n tiene el mecanismo exacto para esto: un workflow puede llamar a otro y esperar su resultado. Son dos nodos, uno a cada lado.
╔═══════════════════════════════════════════════════════════════╗
║ WORKFLOWS DE CANAL (uno por canal, delgados) ║
╠═══════════════════════════════════════════════════════════════╣
║ ║
║ wf_channel_web ║
║ Chat Trigger → Set(normalize) → Execute Sub-workflow ║
║ → Set(format) → responder ║
║ ║
║ wf_channel_whatsapp ║
║ WhatsApp Trigger → IF → Set(normalize) ║
║ → Execute Sub-workflow ║
║ → Code(format) → WhatsApp Send ║
║ ║
║ wf_channel_telegram ║
║ Telegram Trigger → IF → Set(normalize) ║
║ → Execute Sub-workflow ║
║ → Code(format) → Telegram Send ║
║ ║
╚═══════════════════════════════════════════════════════════════╝
│
│ contrato de entrada ▼
│ contrato de salida ▲
│
╔═══════════════════════════════════════════════════════════════╗
║ wf_agent_core (UNO SOLO — el cerebro del Módulo 5) ║
╠═══════════════════════════════════════════════════════════════╣
║ Execute Sub-workflow Trigger ║
║ └─► AI Agent: triage_agent ║
║ ├─ Postgres Chat Memory ║
║ ├─ AI Agent Tool: order_specialist ║
║ └─ AI Agent Tool: billing_specialist ║
╚═══════════════════════════════════════════════════════════════╝
Del lado del núcleo, el nodo Execute Sub-workflow Trigger (que en la lista aparece como "When Executed by Another Workflow") reemplaza al Chat Trigger. Es un disparador que no escucha a internet: solo se activa cuando otro workflow lo llama. Su parámetro clave es cómo declara los datos que espera recibir: puede aceptar todo lo que le manden, o declarar campos con nombre y tipo. Declara los campos. Cuesta un minuto y convierte un contrato implícito en uno que n8n verifica por ti.
Del lado del canal, el nodo Execute Sub-workflow llama al núcleo. Los parámetros que importan:
- Source / Workflow — cuál workflow llamar. Se elige de una lista.
- Workflow Inputs — los valores que le pasas, uno por cada campo que el trigger declaró.
- Mode — si procesa todos los items juntos o uno por uno. Para una conversación siempre hay un item, así que da igual; conviene saber que existe.
- Wait For Sub-Workflow Completion — si el canal espera el resultado. Para un agente conversacional, sí: necesitas la respuesta para poder enviarla.
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 de abajo.
El contrato
Aquí está el corazón de la lección. Un contrato es un acuerdo sobre qué campos viajan, con qué nombre y qué significa cada uno. Es lo mismo que hiciste en el Módulo 5 entre agentes; ahora es entre capas.
Contrato de entrada — del canal al núcleo
{
"channel": "whatsapp",
"channel_user_id": "5215512345678",
"customer_id": "C-9931",
"display_name": "Ana",
"text": "Hola, ¿cómo va mi pedido #4521?",
"locale": "es-MX",
"message_id": "wamid.HBgN..."
}
Campo por campo, y por qué cada uno está ahí:
| Campo | Qué es | Por qué |
|---|---|---|
channel | web, whatsapp, telegram, voice | Lo usa el núcleo solo para modular la longitud de la respuesta (la excepción acotada de la lección 6). Nada más. |
channel_user_id | La identidad que da el canal | Teléfono, chat ID, sessionId. Siempre presente. |
customer_id | La identidad real de TuTienda, si se conoce | Puede venir vacío: un visitante anónimo del chat web no tiene uno. |
display_name | Nombre para saludar | Sale del perfil del canal. No es un dato verificado. |
text | Lo que dijo la persona, en texto plano | Si vino un audio o un botón, el adaptador ya lo convirtió a texto aquí. |
locale | Idioma y región | Útil para formatear fechas y montos, y si algún día atiendes en dos idiomas. |
message_id | El identificador del mensaje en el canal | Para trazabilidad: poder atar una ejecución a un mensaje concreto cuando algo se investiga. |
Dos decisiones de ese contrato merecen defensa explícita.
text siempre es texto plano. Un botón de Telegram no llega como order:4521: el adaptador ya lo tradujo a "Consultar el estado del pedido 4521." Un audio de WhatsApp ya se transcribió. El núcleo recibe siempre lenguaje, nunca un formato de canal. Esa es la regla que mantiene al cerebro hablando un solo idioma.
customer_id puede venir vacío, y el núcleo tiene que tolerarlo. Es tentador exigirlo siempre, pero el chat web con visitantes anónimos existe y es legítimo. Un contrato que no admite el caso real más común de un canal no es un contrato: es una aspiración.
Contrato de salida — del núcleo al canal
{
"text": "¡Hola, Ana! Revisé las dos cosas. El cobro de $1,200 no aparece asociado a ninguna compra tuya, así que abrimos la disputa #D-8842…",
"status": "resolved",
"needs_human": false,
"quick_replies": [
{ "label": "Ver seguimiento", "value": "track:4521" },
{ "label": "Necesito otra cosa", "value": "menu:other" }
],
"attachments": [],
"session_key": "customer:C-9931"
}
Fíjate en quick_replies. El núcleo no sabe si el canal tiene botones, así que no los construye: propone opciones, en un formato neutro. El adaptador de Telegram las convierte en un teclado en línea; el de WhatsApp en botones interactivos o en una lista, según lo que su versión soporte; el del chat web las ignora o las muestra como sugerencias; el de voz las convierte en una frase hablada de tres opciones. Una estructura, cuatro montajes.
Ese es el patrón general de un buen contrato de salida: decir la intención, no la implementación. "Ofrece estas dos opciones" es intención. "Manda un inline_keyboard con dos filas" es implementación, y en cuanto la metes en el núcleo, el núcleo aprendió de Telegram.
needs_human cumple el mismo papel: el núcleo dice que este caso hay que escalarlo, y cada canal decide cómo —en la web puede mostrar un botón de chat con una persona, en WhatsApp notificar a un grupo interno, en voz transferir la llamada—.
Ejemplo trabajado: los cuatro workflows de TuTienda
Vamos a construirlo. Empezamos por el núcleo, porque igual que en el Módulo 5, la pieza de abajo se prueba sola.
El núcleo — wf_agent_core
Paso 1 — Reemplazar el trigger. Abre el workflow del Módulo 5, borra el Chat Trigger y pon un Execute Sub-workflow Trigger en su lugar. Declara los campos:
# Nodo: Execute Sub-workflow Trigger — Name: core_input
# Input data mode: Define using Fields Below
# (confirma la etiqueta exacta en tu versión)
channel string
channel_user_id string
customer_id string
display_name string
text string
locale string
message_id string
Paso 2 — Conectar el agente al campo correcto. Como el trigger ya no es un Chat Trigger, hay que indicarle al agente de dónde leer — el mismo ajuste que hiciste en WhatsApp y en Telegram:
# 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' }}
# canal: {{ $json.channel }}
Ese bloque de contexto al final es lo que permite al agente saludar por su nombre y saber si tiene identidad de cliente, sin que el system prompt cambie por canal. Y channel es lo que la lección 6 usa para modular la longitud.
Paso 3 — La clave de memoria. Aquí está la decisión que atraviesa el módulo, y merece su propia sección más abajo. Por ahora, la versión que funciona:
# Nodo: Postgres Chat Memory
Session ID: Define below
Key: {{ $json.customer_id
? 'customer:' + $json.customer_id
: $json.channel + ':' + $json.channel_user_id }}
Si se conoce el 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.
Paso 4 — Componer la salida. El último nodo del núcleo es un Set que 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 }}
En una versión más elaborada, status, needs_human y quick_replies los produce el propio agente con un Structured Output Parser —exactamente como los especialistas del Módulo 5 producen su JSON— en vez de fijarlos aquí. Empieza con valores fijos y evoluciona a eso cuando el sistema básico funcione.
Paso 5 — Probarlo solo. Igual que probaste cada especialista aislado en el Módulo 5. Ejecuta el núcleo desde el editor con datos fijos:
# Ejecución de prueba del núcleo, sin ningún canal
{
"channel": "whatsapp",
"channel_user_id": "5215512345678",
"customer_id": "C-9931",
"display_name": "Ana",
"text": "¿cómo va mi pedido #4521?",
"locale": "es-MX",
"message_id": "test-001"
}
Qué esperar. El agente delega, resuelve, y el nodo core_output entrega el objeto del contrato con el texto compuesto. Si esto funciona, tienes un cerebro probado y los canales se vuelven un problema separado. Perfecto: acabas de convertir un problema de cuatro variables en dos de dos.
Un adaptador — wf_channel_whatsapp
Cuatro nodos y ninguna sorpresa, porque ya los escribiste en la lección 3:
WhatsApp Trigger
→ IF: is_text_message
→ Set: normalize_incoming
→ Execute Sub-workflow: wf_agent_core
→ Code: format_for_whatsapp
→ WhatsApp Business Cloud: Send
El Set de normalización, ahora produciendo el contrato completo:
# Nodo: Set — Name: normalize_incoming
channel = "whatsapp"
channel_user_id = {{ $json.entry[0].changes[0].value.messages[0].from }}
customer_id = "" # se resuelve en el paso siguiente
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 }}
La llamada al núcleo:
# Nodo: Execute Sub-workflow — Name: call_core
Workflow: wf_agent_core
Wait For Sub-Workflow Completion: activado
Workflow Inputs:
channel = {{ $json.channel }}
channel_user_id = {{ $json.channel_user_id }}
customer_id = {{ $json.customer_id }}
display_name = {{ $json.display_name }}
text = {{ $json.text }}
locale = {{ $json.locale }}
message_id = {{ $json.message_id }}
Y el formateo de salida, que es la lección 6 hecha nodo:
# Nodo: Code — Name: format_for_whatsapp
// Convierte el Markdown estándar del agente a la marca de WhatsApp
// y parte el mensaje si supera el límite duro del canal.
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;
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);
return chunks.map(c => ({ json: { text: c } }));
Cinco nodos. Eso es un canal completo. Y el de Telegram es igual con otros tres nombres.
La decisión de identidad
Esta es la pregunta que la lección 1 dejó sembrada y que hay que resolver a propósito: ¿el historial de conversación pertenece al canal o al cliente?
Las dos respuestas son defendibles y llevan a sistemas distintos.
Opción A — La conversación pertenece al canal
session_key = "whatsapp:5215512345678"
session_key = "telegram:987654321"
session_key = "web:a2f0c8b1e4d7"
A favor: es simple, no requiere ninguna tabla adicional, no puede confundir a dos personas, y cada canal es un compartimento estanco. Es lo correcto cuando los canales atienden públicos distintos —por ejemplo, WhatsApp para clientes y Telegram para un canal interno de soporte técnico— o cuando la identidad real no se conoce.
En contra: el mismo cliente en dos canales son dos desconocidos. Lo que contó por WhatsApp el lunes no existe cuando escribe por la web el miércoles.
Opción B — La conversación pertenece al cliente
session_key = "customer:C-9931" ← desde cualquier canal
A favor: es lo que la gente espera. Nadie entiende por qué tiene que volver a explicar su caso solo porque cambió de aplicación. En un sistema de atención, esta continuidad se nota mucho.
En contra: requiere resolver channel_user_id → customer_id, cosa que no siempre es posible. Y tiene un riesgo real: si la resolución se equivoca, un cliente lee la conversación de otro. Eso es un incidente de privacidad, no un error de funcionamiento.
La resolución de identidad
Para la opción B hace falta una tabla que ate cada identidad de canal a un cliente:
-- Tabla: channel_identities
-- Ata cada identidad que da un canal con el cliente real de TuTienda.
CREATE TABLE channel_identities (
channel TEXT NOT NULL, -- 'whatsapp' | 'telegram' | 'web' | 'voice'
channel_user_id TEXT NOT NULL, -- teléfono, chat id, session id
customer_id TEXT NOT NULL, -- 'C-9931'
verified_at TIMESTAMPTZ NOT NULL DEFAULT now(),
PRIMARY KEY (channel, channel_user_id)
);
Y en el adaptador, un nodo de consulta antes de llamar al núcleo:
# Nodo: Postgres — Name: resolve_customer
# Va entre normalize_incoming y la llamada al núcleo.
SELECT customer_id
FROM channel_identities
WHERE channel = '{{ $json.channel }}'
AND channel_user_id = '{{ $json.channel_user_id }}';
# Si no hay fila, customer_id queda vacío y el núcleo usa la clave
# del canal. No es un error: es un cliente todavía no identificado.
Ahora, la parte importante: cómo se llena esa tabla. Hay tres formas, con niveles de confianza muy distintos:
- Desde una sesión autenticada. El cliente inició sesión en tutienda.example y el chat va embebido ahí. Tu servidor conoce el
customer_idcon certeza y lo pasa pormetadata, como viste en la lección 2. Confianza alta. - Desde el teléfono, si el teléfono ya está en tu CRM. Un cliente que compró dando su número y escribe desde ese mismo número es, casi con seguridad, esa persona. Confianza razonable para consultas de sus propios pedidos; insuficiente para acciones sensibles.
- Preguntándole al cliente. El agente pide un correo o un número de pedido y con eso resuelve. Es la vía más común y también la más débil: cualquiera puede decir el correo de otro.
Y de ahí sale la regla que hace que esta arquitectura sea defendible:
La identidad para recordar y la identidad para actuar no son la misma.
Reconocer a alguien para retomar una conversación tiene un umbral bajo: si te equivocas, alguien ve un contexto que no le corresponde, cosa incómoda pero acotada. Ejecutar una cancelación o un reembolso a nombre de alguien tiene un umbral mucho más alto y exige verificación real.
La versión sensata para el nivel de esta guía: usa la identidad resuelta para la memoria y para personalizar, y exige una verificación adicional —un código enviado al correo registrado, por ejemplo— antes de cualquier acción que mueva dinero o cancele algo. El tratamiento completo de límites de confianza y permisos es el Módulo 7, que viene justo después; esta lección te deja el punto exacto donde ese tema entra.
Qué cuesta esta arquitectura
Ninguna decisión de diseño es gratis y conviene poder nombrar el precio.
Latencia adicional. Llamar a un sub-workflow agrega el arranque de una ejecución más. En la práctica es del orden de décimas de segundo — despreciable frente a los ocho o quince segundos que tarda un sistema multi-agente, y perfectamente relevante si tu agente respondía en 400 milisegundos. Esta es una de las razones por las que la lección 5 recomienda exponer las tools de dominio directamente a la plataforma de voz en vez de pasarlas por el núcleo: en voz, cada capa cuesta.
Una traza partida en dos. Ahora hay una ejecución del canal y otra del núcleo, en registros separados. Depurar exige saltar entre las dos. n8n las enlaza, pero es una fricción real. La mitigación práctica es propagar el message_id en los dos lados: con él puedes encontrar las dos mitades de un caso concreto.
Una indirección más para entender. Quien llega nuevo al proyecto tiene que entender que el cerebro no está en el workflow del canal. Se resuelve con nombres claros —wf_channel_* y wf_agent_core— y una nota en la descripción del workflow.
Cuándo no vale la pena
Tres casos honestos donde esta arquitectura es complejidad sin retorno:
Un solo canal, y no hay plan de agregar otro. Si atiendes solo por WhatsApp y así va a seguir, partir el workflow en dos te da la indirección y ninguna de las ventajas. Constrúyelo de una pieza y parte el día que aparezca el segundo canal — que es un trabajo de media hora si los nombres de campo ya estaban normalizados.
Canales con lógica de negocio genuinamente distinta. Si el bot interno de Telegram del equipo de operaciones hace cosas que el agente de clientes no debe hacer nunca —consultar márgenes, ver datos de otros clientes—, no son el mismo agente con dos puertas: son dos agentes. Forzarlos a compartir núcleo produce un prompt lleno de condicionales, que es peor que dos prompts.
Voz en tiempo real. Ya visto: la plataforma es el cerebro y n8n son las tools. El núcleo conversacional de esta lección no participa de la llamada, aunque sí participa del después —el webhook de fin de llamada puede escribir en la misma memoria y en la misma tabla de identidades, y eso es lo que hace que el cliente que llamó el lunes sea reconocido cuando escribe el martes.
Errores comunes
Poner la lógica de canal dentro del núcleo con un Switch (conceptual). Qué pasa: alguien monta el núcleo y, dentro, agrega un Switch por channel que formatea distinto en cada rama. El núcleo vuelve a saber de Telegram y de WhatsApp, y crece cada vez que agregas un canal — que es exactamente lo que la arquitectura venía a evitar, ahora con un sub-workflow de por medio. Por qué pasa: es tentador centralizar "todo lo de la respuesta" en un lugar. Cómo detectarlo: busca la palabra whatsapp o telegram dentro del núcleo; si aparece en algún lado que no sea la lista de verbosidad del system prompt, es esto. Cómo corregirlo: el formateo vive en el adaptador de salida de cada canal. El núcleo produce un contrato neutro y ahí termina su responsabilidad.
Nombres de campo distintos en cada adaptador (práctico). Qué pasa: el adaptador de WhatsApp produce phone, el de Telegram produce chat_id, el de la web produce session. El núcleo tiene que aceptar los tres, y termina lleno de expresiones con respaldos encadenados que nadie entiende dos meses después. Por qué pasa: cada adaptador se escribe en un momento distinto, y el nombre "natural" de cada canal es distinto. Cómo detectarlo: abre los Set de normalización de tus canales y compáralos lado a lado; si los nombres no son idénticos, es esto. Cómo corregirlo: el contrato se escribe una vez y primero, antes de construir el segundo adaptador. Todos los adaptadores producen exactamente esos nombres, aunque en su canal se llamen de otra forma. Ese es todo el trabajo de un adaptador.
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 o con lo que había antes, el cliente recibe un mensaje en blanco o un error, y en n8n las dos ejecuciones aparecen exitosas. Por qué pasa: la opción de esperar la finalización existe porque hay casos donde no quieres esperar —disparar un proceso en segundo plano— 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 en el nodo Execute Sub-workflow. Para una conversación, siempre.
Resolver la identidad con un dato que el cliente controla (conceptual). Qué pasa: alguien resuelve el customer_id a partir de un correo que el cliente escribió en el chat, y guarda esa asociación en la tabla como si fuera verificada. A partir de ahí, cualquiera que diga el correo de otra persona hereda su historial de conversación. Por qué pasa: es la forma más simple de identificar y funciona en el 99% de los casos, que son clientes honestos. Cómo detectarlo: pregúntate qué le costaría a alguien hacerse pasar por otro cliente en tu sistema; si la respuesta es "saber su correo", es esto. Cómo corregirlo: distingue la identidad para recordar de la identidad para actuar. Puedes usar un dato declarado para personalizar y retomar contexto; para cualquier acción con consecuencias, exige una verificación real. Y guarda en la tabla cómo se verificó cada identidad, no solo cuál es — la columna verified_at del ejemplo debería acompañarse de una que diga por qué medio.
Duplicar el nodo de memoria en cada canal (práctico). Qué pasa: alguien deja la memoria en los workflows de canal en vez de en el núcleo, "para que cada canal maneje la suya". El resultado es que el mismo cliente tiene tantos historiales como canales, y que la lógica de la clave de sesión está escrita cuatro veces con cuatro variantes sutilmente distintas. Por qué pasa: la memoria se siente parte de la conversación, y la conversación se siente parte del canal. Cómo detectarlo: cuenta cuántos nodos de memoria hay en tu instancia para este sistema; si son más de uno, es esto. Cómo corregirlo: la memoria es del agente, y el agente vive en el núcleo. La única cosa que el canal aporta a la memoria son los datos de identidad que van en el contrato.
Ejercicios
Ejercicio 1 — Escribe el adaptador de Telegram. Con el contrato de esta lección, escribe los cuatro nodos del adaptador de Telegram: el filtro, la normalización, la llamada al núcleo y el formateo de salida. Tiene que manejar los dos casos: un mensaje de texto y un botón (callback_query).
Ver solución
Telegram Trigger (updates: Message, Callback Query)
│
├── rama A: ¿trae `message`?
│ └─► Set: normalize_from_message
│
└── rama B: ¿trae `callback_query`?
├─► Telegram → Callback → Answer Query (primero, siempre)
└─► Set: normalize_from_callback
│
└─► (las dos ramas convergen)
└─► Execute Sub-workflow: wf_agent_core
└─► Code: format_for_telegram
└─► Telegram → Send Message
Las dos normalizaciones producen el mismo contrato, que es el punto entero del ejercicio:
# Set: normalize_from_message
channel = "telegram"
channel_user_id = {{ $json.message.from.id }}
customer_id = ""
display_name = {{ $json.message.from.first_name }}
text = {{ $json.message.text }}
locale = {{ $json.message.from.language_code }}
message_id = {{ $json.message.message_id }}
# Set: normalize_from_callback
# La diferencia clave: `text` NO es el callback_data crudo.
# Se traduce a lenguaje aquí, porque el núcleo solo entiende español.
channel = "telegram"
channel_user_id = {{ $json.callback_query.from.id }}
customer_id = ""
display_name = {{ $json.callback_query.from.first_name }}
text = {{ $json.callback_query.data.startsWith('order:')
? 'Consultar el estado del pedido ' + $json.callback_query.data.split(':')[1] + '.'
: 'El cliente eligió la opción: ' + $json.callback_query.data }}
locale = "es-MX"
message_id = {{ $json.callback_query.message.message_id }}
Y el formateo de salida, que además construye los botones a partir de quick_replies:
# Code: format_for_telegram
// Texto plano: sin Parse Mode nada falla nunca.
let text = $json.text
.replace(/\*\*(.+?)\*\*/g, '$1') // quita las negritas de Markdown
.replace(/^#{1,6}\s+/gm, '')
.replace(/^[\-\*]\s+/gm, '• ');
// Las opciones neutras del núcleo se vuelven un teclado de Telegram.
const keyboard = ($json.quick_replies || []).map(
qr => [{ text: qr.label, callback_data: qr.value }]
);
return [{ json: { text, reply_markup: { inline_keyboard: keyboard } } }];
Lo que hay que ver en esa solución: el núcleo no se entera de que existe un botón. Recibe una frase en español y devuelve un texto más una lista de opciones neutra. Toda la mecánica de Telegram —el callback_query, el Answer Query, el inline_keyboard— vive en el adaptador, que es de dónde no sale.
Confirma la forma exacta que espera tu versión del nodo para el teclado: en algunas se arma con el constructor visual y no con un objeto JSON.
Por qué funciona: dos entradas muy distintas producen el mismo contrato, y eso es literalmente la definición de un adaptador.
Ejercicio 2 — Decide la identidad para tres escenarios. Para cada uno, di si usarías identidad por canal (opción A) o unificada por cliente (opción B), cómo la resolverías, y qué verificación adicional exigirías antes de una acción sensible.
- TuTienda: atención al cliente por WhatsApp y por el chat de la web, donde muchos visitantes no han iniciado sesión.
- Un bot interno de Telegram para que el equipo de operaciones consulte inventario.
- Un agente de voz que atiende un número de soporte y también contesta por WhatsApp.
Ver solución
1. Unificada (B), con la resolución que se pueda. Es el caso donde más se nota la continuidad. En WhatsApp, resuelve el customer_id buscando el teléfono en el CRM. En la web, desde la sesión autenticada cuando la hay, y customer_id vacío cuando no — que es correcto: un visitante anónimo no es nadie todavía, y la clave por canal es la respuesta apropiada. Para acciones sensibles (cancelar, reembolsar, cambiar datos), un código enviado al correo registrado. El teléfono como prueba de identidad es razonable para consultar el propio pedido y no para mover dinero.
2. Por canal (A), y ni siquiera hace falta la tabla. El from.id de Telegram es la identidad del empleado, y la lista de quién puede usar el bot es una lista corta y explícita —Restrict to User IDs en el trigger hace la mitad del trabajo—. No hay ningún customer_id que resolver porque no hay clientes. Este caso es además el ejemplo del apartado "cuándo no vale la pena": si este bot consulta márgenes y datos internos, probablemente no debería compartir núcleo con el agente de clientes.
3. Unificada (B), y aquí es donde más valor tiene. El número desde el que alguien llama y el número desde el que escribe por WhatsApp suelen ser el mismo, así que la unificación sale casi gratis: una fila en la tabla de identidades sirve para los dos canales. El resultado es notable — el cliente llama, no se resuelve del todo, y al día siguiente escribe por WhatsApp y el agente ya sabe de qué se trata. Para acciones sensibles, en voz conviene ser más estricto que en texto, porque el reconocimiento de voz puede confundir dígitos: repetición y confirmación explícita del dato crítico, más el mismo código al correo.
El patrón que atraviesa los tres: la identidad unificada vale la pena cuando los canales atienden a la misma persona sobre los mismos temas. Cuando atienden públicos distintos o temas distintos, unificar no aporta nada y sí agrega riesgo.
Por qué funciona: el ejercicio muestra que la decisión no es técnica sino de producto, y que la respuesta cambia según a quién atiende cada canal — cosa que no se ve mirando el diagrama de nodos.
Ejercicio 3 — Migra y mide. Toma dos de los canales que montaste en las lecciones anteriores y migralos a esta arquitectura. Después mide y anota: (a) cuántos nodos tenías antes en total y cuántos tienes ahora; (b) cuánto tardaba una respuesta antes y cuánto tarda ahora, con cinco corridas de cada una; (c) haz un cambio real al system prompt del triage_agent y cronometra cuánto tardas en aplicarlo y verificarlo.
Ver solución
Los números dependen de tu sistema, pero el patrón que sale es este:
(a) Con dos canales, el ahorro de nodos es modesto —quizá pasas de dieciséis a trece— y ahí conviene una observación honesta: con dos canales esta arquitectura casi no se paga sola en cantidad de nodos. Se paga en el punto (c). El ahorro en nodos crece de forma no lineal con cada canal que agregas, porque el núcleo no crece.
(b) La diferencia de latencia debería ser de décimas de segundo, imperceptible frente a los ocho a quince segundos de un sistema multi-agente. Si mides más de un segundo de diferencia, revisa que no estés llamando al núcleo dos veces o que la opción de esperar la finalización esté bien configurada.
(c) Aquí está el resultado que importa. Antes: dos ediciones, dos guardados, dos pruebas, y la responsabilidad de recordar que había dos. Ahora: una edición, un guardado, y una prueba por canal para confirmar que la salida se sigue formateando bien — que es una prueba distinta y más corta, porque solo estás verificando el adaptador, no el razonamiento.
La observación que suele aparecer y vale la pena registrar: cambió también el tipo de error posible. Antes el riesgo era la divergencia silenciosa entre copias, que no se detecta hasta que un cliente la sufre. Ahora el riesgo es romper los dos canales a la vez con un cambio malo, que se detecta de inmediato porque nada funciona. El segundo tipo de error es enormemente preferible: un fallo ruidoso e inmediato siempre vale más que uno silencioso y diferido.
Por qué funciona: el ejercicio te da los tres números con los que se defiende esta decisión ante alguien que pregunte por qué el sistema tiene un workflow más de lo que parecía necesario.
Resumen y siguiente paso
Ya tienes la arquitectura completa. El cerebro del Módulo 5 vive en un solo workflow, wf_agent_core, disparado por un Execute Sub-workflow Trigger que declara sus campos de entrada. Cada canal es un workflow delgado de cuatro o cinco nodos: recibe, normaliza al contrato, llama al núcleo, formatea la salida según su canal, y envía. Entre las dos capas hay un contrato explícito —siete campos de entrada, seis de salida— donde el núcleo dice la intención (quick_replies, needs_human) y cada adaptador decide la implementación.
Y resolviste la pregunta de identidad que atravesaba el módulo, con una regla que vale más allá de este caso: la identidad para recordar y la identidad para actuar no son la misma cosa, y el umbral de confianza de la segunda es mucho más alto.
Antes de avanzar deberías poder: dibujar de memoria los dos nodos que conectan un canal con el núcleo; explicar por qué quick_replies viaja como una lista neutra y no como un teclado de Telegram; y nombrar dos casos concretos donde esta arquitectura es complejidad sin retorno.
Lo que sigue es el mini-proyecto. La lección 8 pone todo el módulo junto: el mismo agente atendiendo simultáneamente por chat web y por WhatsApp, con memoria compartida, adaptación por canal, y una batería de casos de prueba que incluye el que más sistemas reprueba —el mismo cliente empezando una conversación en un canal y continuándola en el otro—. Y cierra el módulo con el puente al Módulo 7, que es donde estos canales que acabas de abrir a internet reciben sus cerraduras.
Recursos
- Execute Sub-workflow node — n8n Docs — el nodo que llama al núcleo desde cada canal, con sus modos y la opción de esperar la finalización.
- Execute Sub-workflow Trigger — n8n Docs — el disparador del núcleo y la declaración de campos de entrada que convierte tu contrato en algo que n8n verifica.
- Sub-workflows — n8n Docs — la guía conceptual, con el detalle de cómo viajan los datos entre el workflow padre y el hijo.
- Set node (Edit Fields) — n8n Docs — el nodo con el que se escriben los dos adaptadores, y el que hace explícito el contrato.
- Memory in n8n — n8n Docs — la clave de sesión, que en esta arquitectura pasa a ser una expresión con respaldo y es la decisión de identidad hecha código.
- Postgres node — n8n Docs — para la tabla de identidades de canal y su consulta de resolución.