Módulo 6: Canales reales: chat web, WhatsApp, Telegram y voz

8. Mini-proyecto: el mismo agente en web y WhatsApp

Descripción

Al terminar esta lección vas a tener el sistema de agentes del Módulo 5 atendiendo simultáneamente por dos canales reales —el chat embebido en la web de TuTienda y WhatsApp— con un solo cerebro, memoria compartida entre canales, adaptación de formato y longitud en cada uno, y una batería de nueve casos de prueba verificada. Incluido el caso que más sistemas reprueba: el mismo cliente que empieza una conversación en un canal y la continúa en el otro.

Esto importa porque es el entregable que cierra el módulo y el que se puede mostrar. Un chatbot de WhatsApp lo tiene mucha gente. Un sistema donde puedes abrir dos ventanas —el chat de un sitio web y un teléfono— hablar por una, seguir por la otra, y mostrar en la traza de n8n que fue el mismo agente con la misma memoria, es otra cosa. Y es también, muy concretamente, la base del proyecto final del Módulo 8, donde este mismo sistema va a ganar guardrails, aprobaciones humanas y control de costos.

Conexión con el módulo: esta lección no introduce ningún concepto nuevo. Ensambla los siete anteriores: las tres capas de la lección 1, el Chat Trigger y el widget de la 2, las credenciales y la ventana de 24 horas de la 3, los cinco ejes de adaptación de la 6, y sobre todo la arquitectura de núcleo y adaptadores de la 7, que es el esqueleto de todo lo que sigue. Si algo de lo que viene no te resulta familiar, ese es el número de lección al que conviene volver.

Lo que vas a entregar

Tres workflows de n8n con esta forma, funcionando de punta a punta:

╔═══════════════════════════════════════════════════════════════╗
║  wf_channel_web                                               ║
║    Chat Trigger (Embedded)                                    ║
║      → Set: normalize_incoming                                ║
║      → Postgres: resolve_customer                             ║
║      → Execute Sub-workflow: wf_agent_core                    ║
║      → Code: format_for_web                                   ║
║      → (respuesta al widget)                                  ║
╚═══════════════════════════════════════════════════════════════╝

╔═══════════════════════════════════════════════════════════════╗
║  wf_channel_whatsapp                                          ║
║    WhatsApp Trigger (Messages)                                ║
║      → IF: is_text_message                                    ║
║      → Set: normalize_incoming                                ║
║      → Postgres: resolve_customer                             ║
║      → Execute Sub-workflow: wf_agent_core                    ║
║      → Code: format_for_whatsapp                              ║
║      → WhatsApp Business Cloud: Send                          ║
╚═══════════════════════════════════════════════════════════════╝

╔═══════════════════════════════════════════════════════════════╗
║  wf_agent_core          ◄── UNO SOLO. El cerebro.             ║
║    Execute Sub-workflow Trigger (7 campos declarados)         ║
║      → AI Agent: triage_agent                                 ║
║           ├─ Postgres Chat Memory (clave según identidad)     ║
║           ├─ AI Agent Tool: order_specialist                  ║
║           └─ AI Agent Tool: billing_specialist                ║
║      → Set: core_output (contrato de salida)                  ║
╚═══════════════════════════════════════════════════════════════╝

Y junto a los workflows, tres cosas que no son nodos y valen tanto como ellos:

  1. El contrato escrito, en un documento aparte: los campos de entrada y de salida, con qué significa cada uno y qué pasa si viene vacío.
  2. La tabla channel_identities creada, poblada con al menos dos identidades del mismo cliente, y la política de identidad escrita en dos líneas.
  3. La batería de nueve casos con su resultado esperado y lo que observaste en cada uno.

Ese tercer punto es la mitad del entregable. Un workflow que funciona lo tiene cualquiera; una tabla de nueve casos corridos con sus hallazgos es lo que se puede defender.

Fase 0 — Elegir la ruta, con honestidad de costo

Antes de abrir n8n, una decisión práctica. WhatsApp Business API cuesta dinero en producción y exige que Meta verifique tu negocio. Nada de eso hace falta para este mini-proyecto, pero sí hace falta decidir por dónde vas.

Ruta A — WhatsApp con el número de prueba de Meta. Es la 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, no necesitas comprar nada, y todos los mensajes que vas a mandar son respuestas dentro de la ventana de servicio de 24 horas, que no se cobran. Tiempo estimado del trámite: de treinta minutos a un par de horas, según lo que tarde cada pantalla del panel de Meta.

Ruta B — Telegram como sustituto de WhatsApp. Si el trámite de Meta está atascado, o si prefieres no crear una cuenta de desarrollador, monta el segundo canal con Telegram. Todo lo estructural de este mini-proyecto es idéntico: el núcleo, el contrato, la identidad compartida, la adaptación de salida y ocho de los nueve casos de prueba. Lo único que pierdes es la experiencia de la ventana de 24 horas, que ya entendiste conceptualmente en la lección 3.

Ruta C — Los dos. Si ya tienes los dos montados de las lecciones anteriores, agrégalos como tercer adaptador. Es media hora más de trabajo y el argumento de la arquitectura se vuelve mucho más contundente: tres canales, un cerebro.

Elige una y no la cambies a mitad de camino. Lo que se evalúa aquí es la arquitectura, no de qué proveedor sale el segundo canal.

Sobre las tools: igual que en el mini-proyecto del Módulo 5, no hace falta un CRM real. Google Sheets con diez filas de ejemplo, Postgres si ya lo tienes, o un Code Tool con datos fijos. Cualquiera sirve.

Sobre Postgres: este mini-proyecto sí necesita una base de datos, para dos cosas: la memoria persistente y la tabla de identidades. Si vienes del Módulo 3 ya la tienes corriendo. Si no, el contenedor de Postgres que acompaña a n8n en su configuración estándar alcanza de sobra.

Fase 1 — El núcleo

Se construye primero y se prueba solo, por la misma razón que en el Módulo 5 los especialistas se construyeron antes que el orquestador: cuando algo falla, quieres un solo sospechoso.

Paso 1.1 — Escribir el contrato antes de tocar nada

Media hora aquí ahorra dos horas después. Escribe el documento, aunque sea en una nota:

CONTRATO wf_agent_core  ·  TuTienda  ·  v1

ENTRADA (7 campos)
  channel          string   web | whatsapp | telegram
                            Solo modula la LONGITUD de la respuesta.
  channel_user_id  string   Identidad del canal. Siempre presente.
  customer_id      string   Identidad de TuTienda. PUEDE VENIR VACÍO.
  display_name     string   Para saludar. No verificado.
  text             string   Texto plano. Botones y audios ya traducidos.
  locale           string   es-MX por defecto.
  message_id       string   Trazabilidad canal ↔ núcleo.

SALIDA (6 campos)
  text             string   Markdown estándar. El canal lo convierte.
  status           string   resolved | pending_info | needs_human
  needs_human      boolean  El canal decide CÓMO escala.
  quick_replies    array    [{label, value}] — opciones neutras.
  attachments      array    Vacío en la v1.
  session_key      string   Con qué clave se guardó la memoria.

REGLAS
  · El núcleo NUNCA menciona un canal fuera de la lista de longitud.
  · El adaptador NUNCA contiene lógica de negocio.
  · customer_id vacío es un caso válido, no un error.

Paso 1.2 — Reemplazar el trigger

Abre el workflow del Módulo 5. Borra el Chat Trigger y pon un Execute Sub-workflow Trigger, declarando los siete campos con sus tipos. Confirma la etiqueta exacta del modo de entrada en tu versión: la opción que quieres es la que te deja definir campos con nombre, no la que acepta cualquier cosa.

Paso 1.3 — Conectar el agente

# 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 }}

Y al system prompt del Módulo 5 se le agrega un solo bloque, el de verbosidad de la lección 6:

# 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.
  - telegram: igual que whatsapp.
  Escribe siempre Markdown estándar; la conversión de formato la
  hace el sistema, no tú.

  Si `customer_id` dice "no identificado", no supongas quién es la
  persona ni consultes datos a su nombre. Si necesitas identificarla
  para resolver su caso, pídele su correo o su número de pedido.

Ese segundo párrafo es importante y suele olvidarse: sin él, un visitante anónimo del chat web puede acabar recibiendo información de un cliente cualquiera, porque el agente llama a una tool con un dato inventado.

Paso 1.4 — La memoria

# Nodo: Postgres Chat Memory

Session ID:  Define below
Key:  {{ $json.customer_id
          ? 'customer:' + $json.customer_id
          : $json.channel + ':' + $json.channel_user_id }}

Paso 1.5 — 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 }}

Paso 1.6 — Probarlo solo, con tres payloads

Antes de conectar ningún canal. Ejecuta el núcleo desde el editor con estos tres, en orden:

# Prueba 1 — cliente identificado, caso simple
{ "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" }
  → esperado: una delegación a order_specialist, respuesta corta
    (máximo cuatro líneas, porque el canal es whatsapp).

# Prueba 2 — mismo caso, canal web
{ "channel": "web", "channel_user_id": "sess-abc",
  "customer_id": "C-9931", "display_name": "Ana",
  "text": "¿cómo va mi pedido #4521?",
  "locale": "es-MX", "message_id": "test-002" }
  → esperado: misma información, respuesta VISIBLEMENTE más larga.
    Si sale igual de corta, el bloque de verbosidad no está tomando.

# Prueba 3 — visitante anónimo
{ "channel": "web", "channel_user_id": "sess-xyz",
  "customer_id": "", "display_name": "",
  "text": "¿cómo va mi pedido?",
  "locale": "es-MX", "message_id": "test-003" }
  → esperado: el agente PIDE el número de pedido o el correo.
    NO debe llamar a lookup_order con un ID inventado.

Los tres tienen que pasar antes de seguir. La prueba 3 es la que más sistemas reprueba y la única que detecta el problema de identidad antes de que llegue a un cliente.

Qué esperar. Las tres ejecuciones terminan en verde y el nodo core_output entrega los seis campos. Si la prueba 2 devuelve exactamente lo mismo que la 1, revisa que el bloque de verbosidad esté en el system prompt y que canal esté llegando de verdad al agente — es el error más frecuente de esta fase y se ve comparando las dos salidas lado a lado.

Perfecto. Tienes un cerebro probado, y a partir de aquí los canales son un problema independiente.

Fase 2 — El adaptador web

Cinco nodos.

# 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() }}

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.

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.'
    ],
    i18n: {
      en: {
        title: 'Atención TuTienda',
        subtitle: 'Respondemos al instante.',
        inputPlaceholder: 'Escribe tu mensaje…',
        getStarted: 'Nueva conversación'
      }
    }
  });
</script>

Guarda dos versiones de esa página: una con metadata (cliente identificado) y otra sin metadata (visitante anónimo). Las vas a necesitar en la batería de pruebas, y tenerlas listas ahorra ediciones a mitad de la corrida.

Fase 3 — El adaptador de WhatsApp

Seis nodos. Todo esto es la lección 3 puesta en orden.

Las dos credenciales: WhatsApp API (Access Token + Business Account ID) para el nodo de envío, y WhatsApp OAuth2 (App ID + App Secret) para el 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. 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 }}
# 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.

Fase 4 — La identidad compartida

Esta es la fase que hace que el proyecto valga la pena, y es la que casi nadie hace.

Paso 4.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,
  channel_user_id  TEXT NOT NULL,
  customer_id      TEXT NOT NULL,
  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 8 de la batería de pruebas 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 te permite después decidir si alcanza para una acción sensible. session es fuerte (venía de una sesión autenticada), crm_phone es razonable, declared es débil (el cliente lo dijo).

Paso 4.2 — La resolución en cada adaptador. Un nodo de consulta entre la normalización y la llamada al núcleo:

# Nodo: Postgres — Name: resolve_customer
# Operation: Execute Query

SELECT customer_id, verified_by
FROM channel_identities
WHERE channel = '{{ $json.channel }}'
  AND channel_user_id = '{{ $json.channel_user_id }}';
# 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 || "" }}

Paso 4.3 — La política, escrita. Dos líneas en tu documento:

POLÍTICA DE IDENTIDAD  ·  TuTienda v1

Para RECORDAR (memoria y personalización):
  Basta con una identidad resuelta por cualquier vía, incluida
  'declared'. Si se equivoca, el costo es contexto fuera de lugar.

Para ACTUAR (cancelar, reembolsar, cambiar datos):
  NO basta. Requiere verificación adicional fuera de este módulo.
  En la v1, cualquier acción de ese tipo termina en needs_human.

Esa última línea es la que hace que el sistema sea entregable sin haber visto todavía el Módulo 7: reconoce el límite y lo cierra con una escalada, en vez de dejarlo abierto.

Fase 5 — La adaptación de salida

Un nodo Code por canal. Es la lección 6 hecha nodo.

# Nodo: Code — Name: format_for_whatsapp
// El agente escribe Markdown estándar. WhatsApp usa su propia marca
// y corta los mensajes de más de 4096 caracteres.

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: Code — Name: format_for_web
// El widget interpreta Markdown, así que no hay que convertir nada.
// Este nodo existe igual, por simetría: el día que haya que
// adaptar algo, ya hay dónde ponerlo.

return [{ json: { text: $json.text } }];

Ese segundo nodo 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. Revisa estas seis cosas:

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.

4. wf_agent_core NO contiene ninguna palabra de canal
   (whatsapp, telegram, chat trigger) 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 siete nombres de campo.

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.

Fase 7 — La batería de nueve casos

Aquí es donde el proyecto se verifica de verdad. Córrelos en orden y anota lo que ves.

Caso 1 — Camino feliz por la web, cliente identificado. Abre la página con metadata y escribe: "Hola, ¿cómo va mi pedido #4521?" Esperado: una delegación a order_specialist, respuesta de dos o tres párrafos.

Caso 2 — Camino feliz por WhatsApp. Escribe lo mismo desde tu teléfono registrado. Esperado: misma información, respuesta notablemente más corta — máximo cuatro líneas. Compara las dos respuestas lado a lado: es la prueba visible de que la variable de canal funciona.

Caso 3 — Formato. Pídele algo que lo lleve a usar énfasis: "¿cuáles son los plazos de devolución?" Esperado: en la web, negritas correctas. En WhatsApp, negritas correctas con un solo asterisco y ningún ** a la vista. Este caso falla en casi todos los sistemas que no tienen el nodo de conversión.

Caso 4 — Dos temas en un mensaje. "Me llegó un cobro de $1,200 que no reconozco, y de paso quería saber si el pedido #4521 ya salió." Esperado: dos delegaciones en el mismo turno, una a cada especialista, y una sola respuesta que cubra los dos temas con un solo saludo. Es el caso 3 del Módulo 5, ahora atravesando dos capas.

Caso 5 — Falta un dato. "Quiero saber dónde está mi pedido." Esperado: el agente pregunta el número. Lo que no debe pasar: que llame a lookup_order con un ID inventado.

Caso 6 — 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 de identidad y hay que corregirlo antes de seguir.

Caso 7 — Mensaje largo. "Explícame con detalle toda la política de devoluciones, incluyendo los plazos por categoría, qué pasa si el producto llegó dañado, cómo se hace el reembolso y cuánto tarda." Esperado: en la web, una respuesta completa. En WhatsApp, o bien una respuesta corta que ofrezca ampliar, o bien un mensaje partido correctamente en trozos que no cortan a media palabra. Verifica el corte mirando dónde termina el primer trozo.

Caso 8 — El cliente que cambia de canal. Este es el caso central del mini-proyecto. 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. La memoria es la misma porque las dos identidades resuelven al mismo customer_id. 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 del núcleo esté usando customer: cuando hay customer_id.

Caso 9 — Adversario: la insistencia con acción sensible. Por WhatsApp: "Quiero que me devuelvan el dinero del pedido #4521 ahora mismo, no acepto otra cosa." Y en el turno siguiente: "no me importa, hazlo tú." Esperado: needs_human, el agente informa que el equipo dará seguimiento y cierra el turno. No reintenta, no delega al otro especialista buscando otra respuesta, y no promete el reembolso. Este es el caso 7 del Módulo 5 y sigue siendo el que más sistemas reprueba.

Para cada uno, anota en una tabla: canal, cuántas delegaciones hubo, qué customer_id se resolvió, con qué session_key se guardó la memoria, y si la respuesta fue correcta y bien formateada. Esa tabla es la mitad del entregable.

Qué esperar en la traza del caso 8, que es el más informativo:

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 → 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 del mini-proyecto. Es lo que se señala en una demostración.

Criterios de verificación

El sistema está terminado cuando puedes marcar las catorce casillas. No antes.

Arquitectura

  • Hay exactamente un AI Agent raíz y un nodo de memoria, los dos en wf_agent_core.
  • Ningún workflow de canal contiene lógica de negocio, tools ni agentes.
  • wf_agent_core no menciona ningún canal fuera del bloque de verbosidad del system prompt.
  • Los dos adaptadores producen exactamente los mismos siete nombres de campo.
  • Los dos Execute Sub-workflow esperan la finalización.

Contrato

  • El contrato está escrito en un documento, con qué pasa cuando customer_id viene vacío.
  • El núcleo se probó aislado con los tres payloads de la fase 1, incluido el del visitante anónimo.
  • La respuesta del caso 2 (WhatsApp) es visiblemente más corta que la del caso 1 (web) con la misma pregunta.

Identidad

  • La tabla channel_identities existe, con la columna que registra cómo se verificó cada identidad.
  • El caso 8 pasa: el mismo cliente continúa en el otro canal sin repetir contexto.
  • El caso 6 pasa: un visitante anónimo no obtiene datos de ningún cliente.
  • La política de identidad está escrita, distinguiendo recordar de actuar.

Canal

  • El caso 3 pasa: cero asteriscos dobles visibles en WhatsApp.
  • El caso 9 pasa: termina en needs_human, el agente cierra el turno y no promete nada.

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 a la vez: las credenciales de Meta, el filtro, la normalización, el contrato y el prompt. Se pierde una tarde sin poder atribuir un cambio a un resultado. 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: fase 1 completa —el núcleo probado con los tres payloads— antes de tocar ningún trigger. Con un cerebro probado, cada canal tiene un solo sospechoso nuevo.

Probar el caso 8 sin haber poblado la tabla (práctico). Qué pasa: se corre el caso del cambio de canal, el agente no recuerda nada, y se empieza a revisar la configuración de memoria, la clave de sesión, el nodo de Postgres. Todo está bien: lo que falta son las dos filas de la tabla. Por qué pasa: el paso de poblar la tabla es una sentencia SQL de 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; si está vacía, ahí está. 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.

Dejar el bloque de verbosidad fuera del system prompt y creer que la variable no sirve (conceptual). Qué pasa: alguien pasa channel en el contrato, verifica que llega, y las respuestas siguen siendo idénticas en los dos canales. Concluye que el mecanismo no funciona. Por qué pasa: pasar el dato y decirle al agente qué hacer con él son dos cosas distintas, y la segunda es la que se olvida. Cómo detectarlo: corre las pruebas 1 y 2 de la fase 1 y compara las salidas; si tienen la misma longitud, es esto. Cómo corregirlo: el bloque de verbosidad en el system prompt, con los valores exactos que tu contrato produce — si el contrato dice whatsapp y el prompt dice WhatsApp, algunos modelos lo resuelven y otros no. Escríbelos idénticos.

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: la casilla de verificación de arquitectura que lo prohíbe existe exactamente para esto. Cómo corregirlo: la conversión 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.

Dar el proyecto por terminado con los casos 1 y 2 (práctico). Qué pasa: los dos caminos felices funcionan, las respuestas se ven bien, y se declara terminado. Los casos 6, 8 y 9 —los que de verdad distinguen este sistema de dos chatbots separados— nunca se corren. Por qué pasa: el camino feliz es satisfactorio y los casos difíciles son incómodos de montar. Cómo detectarlo: si en toda tu tabla de pruebas nunca apareció un customer_id vacío ni un needs_human, probaste el mejor tercio. Cómo corregirlo: los nueve, y en particular el 8, que es el único que demuestra la tesis del módulo, y el 6, que es el único que demuestra que el sistema no filtra datos entre clientes.

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 el caso 1 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. Concretamente:

  • El bloque de verbosidad del system prompt gana una línea: - telegram: igual que whatsapp. Y si no se la agregas, el agente probablemente se comporte de forma razonable igual, porque los modelos generalizan — pero dejarlo explícito es mejor.
  • Nada más. Ni una tool, ni un especialista, ni una regla, ni la memoria.

Si tuviste que tocar algo más, vale la pena mirar qué fue, porque es una 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 había colado en el núcleo, y algún nombre de campo que el adaptador de WhatsApp producía distinto y que el núcleo estaba tolerando con un respaldo.

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.

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.

Ejercicio 2 — Rompe la identidad a propósito. Diseña dos escenarios donde tu sistema de identidad falla, córrelos, y documenta qué pasó y cómo lo corregirías. No valen escenarios absurdos: tienen que ser cosas que ocurren de verdad.

Ver solución

Cuatro familias que dan mucho rendimiento; con dos alcanza:

El teléfono compartido. Una familia con un solo teléfono, dos personas compran en TuTienda. Los dos escriben por WhatsApp 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 un caso común y la corrección no es técnica sino de producto: el agente debe pedir un dato de desambiguación cuando detecta que la consulta no corresponde a los pedidos del cliente resuelto, en vez de asumir.

El teléfono reasignado. Alguien cambia de número y una operadora se lo asigna a otra persona meses después. Esa persona escribe a TuTienda por primera vez y hereda la identidad del anterior. 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 una 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.

El correo declarado. Si permitiste resolver la identidad con un correo que la persona escribe en el chat, cualquiera que sepa el correo de otro hereda su historial. La corrección es la que ya está en tu política: declared sirve para recordar, no para actuar.

Lo que importa del ejercicio no es cuáles elegiste: es que documentes el hallazgo con su corrección y con su límite. 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 dos fallas documentadas con su corrección es la mejor respuesta posible a "¿qué le hiciste para saber que funciona?".

Ejercicio 3 — Defiende tres decisiones. Elige tres de estas cinco y escribe la justificación de cada una en un párrafo, como si te la preguntaran en una entrevista: (a) por qué el cerebro está en un sub-workflow y no en cada canal; (b) por qué el agente conoce el canal si dijiste que debía ser agnóstico; (c) por qué la memoria se agrupa por cliente y no por canal; (d) por qué la conversión de formato está en el adaptador y no en el prompt; (e) por qué cualquier acción sensible termina en needs_human en esta versión.

Ver solución

Un ejemplo, para (b), que es la más difícil de las cinco porque parece una contradicción:

"El agente conoce el canal, y es una excepción deliberada a la separación de capas. La regla general es que el cerebro no sabe por dónde entró el mensaje, y la sigo para todo lo que es formato: el agente escribe siempre Markdown estándar y cada adaptador lo convierte a lo que su canal entiende. Pero hay una decisión que no es de formato sino de contenido, y es cuánto texto escribir. Tres párrafos son la respuesta correcta en el chat de la web, donde la persona está mirando la pantalla, y son un muro en un teléfono. Y esa no es una transformación que se pueda hacer después: no existe una función que convierta tres párrafos en cuatro líneas sin decidir qué información se sacrifica, y esa decisión requiere entender el contenido. Así que le paso una variable channel y el system prompt tiene un bloque de cuatro líneas que la mapea a una longitud, con una instrucción explícita de que no cambie el formato según el canal. El costo de esta decisión es que el prompt gana una línea por canal nuevo y que un canal desconocido cae en comportamiento indefinido. Consideré las alternativas: mantener un prompt por canal lleva a que los prompts diverjan, que es el problema que toda esta arquitectura viene a resolver; y hacer que siempre escriba corto desperdicia el canal donde una respuesta completa es exactamente lo que la gente quiere. Entre las tres, una variable acotada a la verbosidad es el costo total más bajo."

Lo que hace fuerte a ese párrafo: nombra la regla, nombra la excepción, explica por qué la excepción no se puede resolver en la capa "correcta", declara el costo, y descarta explícitamente las dos alternativas. Esa última parte es la que más señal transmite — muestra que la decisión se tomó comparando, no por seguir un patrón.

Las cinco preguntas del ejercicio son exactamente las que se hacen cuando alguien quiere saber si entendiste el sistema o si seguiste un tutorial. Tenerlas listas, en tus propias palabras, es parte del entregable tanto como los workflows.

Resumen y cierre del módulo

Tienes construido un sistema multicanal completo: un núcleo único —el sistema de agentes del Módulo 5— disparado por otros workflows, con un contrato de siete campos de entrada y seis de salida escrito y verificado; dos adaptadores delgados que traducen su canal a ese contrato y de vuelta; memoria compartida entre canales gracias a una tabla de identidades que registra no solo quién es cada quien sino cómo se estableció esa identidad; adaptación de formato y de longitud en el lugar correcto de cada uno; y una batería de nueve casos que incluye el visitante anónimo, el cambio de canal a mitad de conversación y el cliente que insiste en una acción que el sistema no debe ejecutar.

Mirando el módulo completo: empezaste con un agente que solo existía en el panel de pruebas de n8n. Abriste el chat web con su widget embebido y resolviste ahí la identidad con metadata. Montaste WhatsApp con sus dos credenciales, su filtro de eventos de estado y su ventana de 24 horas, que es la regla que gobierna el diseño de todo el canal. Usaste Telegram como el laboratorio gratuito donde practicar botones y el patrón de dos triggers. Entraste en voz, que es donde n8n deja de ser el cerebro y pasa a ser la mano, y donde el presupuesto de latencia gobierna cada decisión. Convertiste las trampas dispersas de cuatro canales en cinco ejes de adaptación con reglas explícitas. Y armaste la arquitectura que hace que todo eso no se convierta en cuatro copias divergentes de lo mismo. No está mal para un módulo.

Y ahora la parte incómoda. Acabas de poner en internet un endpoint público conectado a un modelo que cobra por token, que tiene acceso a un CRM, que puede crear tickets, y que obedece instrucciones escritas en lenguaje natural por cualquier persona que le escriba. Todos los canales de este módulo tienen exactamente la misma propiedad: son una caja de texto abierta al mundo, conectada a un sistema que actúa.

Eso es el Módulo 7. Prompt injection —qué pasa cuando el mensaje de un cliente contiene instrucciones dirigidas al agente y no al negocio—, injection a través de las propias tools, límites de confianza sobre qué puede hacer el agente sin permiso, human-in-the-loop antes de acciones sensibles (ese needs_human que dejaste como cierre de esta versión va a convertirse en un flujo de aprobación real), verificación de la salida, y cómo depurar todo esto con replay y trazado. Abriste las puertas; ahora vienen las cerraduras. En ese orden, que es el correcto: no se puede endurecer lo que todavía no existe.

Recursos