Módulo 4: Herramientas: el agente que actúa sobre sistemas reales

8. Mini-proyecto: agente que ejecuta 3 acciones sobre sistemas reales

Descripción

Al terminar esta lección vas a tener construido y funcionando un agente de soporte de TuTienda con tres tools que cubren las tres cosas que un agente hace sobre sistemas reales —buscar, crear y enviar—, y, tan importante como tenerlo construido, vas a poder demostrar que actuó de verdad: con la traza de ejecución de n8n, con una consulta a la base de datos y con un correo que llegó a una bandeja. No con lo que el agente dice que hizo.

Esto importa porque es la diferencia exacta entre lo que sabes y lo que puedes mostrar. En una entrevista, "sé conectar tools a un agente de n8n" no dice nada; abrir una traza de ejecución y señalar el momento en que el modelo eligió una tool, los argumentos con los que la llamó y la fila que apareció en la base de datos, sí. Y en el trabajo real es lo mismo: nadie te va a preguntar cuántos nodos conectaste, te van a preguntar cómo sabes que el agente no está inventando confirmaciones. Este mini-proyecto es la primera vez en la guía en que la respuesta a esa pregunta tiene que salir de ti.

Conexión con el módulo: este es el cierre del Módulo 4 y usa, en un solo workflow, todo lo que construiste desde la lección 2. La tool de búsqueda aplica lo de la lección 4 —el nodo Postgres contra el sistema real, con query parametrizada y el campo de identidad fijo—. La tool de creación es un sub-workflow expuesto como tool, exactamente el patrón de la lección 6, con su regla de negocio adentro. La tool de envío es el nodo Gmail de la lección 4, ahora con el contrato riguroso de la lección 5. Y de la lección 7 vas a tomar una extensión opcional, si quieres llevar el proyecto un paso más allá. No hay conceptos nuevos en esta lección: hay integración, verificación y criterio.

Qué vas a construir

Un cliente escribe al chat que su pedido llegó dañado y pide un reemplazo. El agente tiene que hacer tres cosas, en este orden, y ninguna de las tres puede ser inventada:

  1. Buscar el pedido en la base de datos real de la tienda, para saber si existe, si es de esa persona y en qué estado está.
  2. Crear la solicitud de reemplazo, aplicando la política de la empresa —que tiene una excepción por categoría de producto— y dejando el registro en la base de datos.
  3. Enviar un aviso al equipo de soporte humano con el resultado, para que alguien le dé seguimiento.

Así se ve el sistema completo:

                        ┌──────────────────────────┐
   cliente ──▶ Chat Trigger ──▶ │        AI Agent          │ ──▶ respuesta
                        │  (modelo + System Message │
                        │   + memoria de Postgres)  │
                        └────────────┬─────────────┘
                                     │ puerto ai_tool
                 ┌───────────────────┼───────────────────┐
                 │                   │                   │
        ┌────────▼────────┐ ┌────────▼─────────┐ ┌───────▼────────┐
        │   BUSCAR        │ │     CREAR        │ │    ENVIAR      │
        │  lookup_order   │ │ create_replace-  │ │ notify_support │
        │  (Postgres)     │ │ ment_request     │ │ _team (Gmail)  │
        │                 │ │ (Call n8n Work-  │ │                │
        │                 │ │  flow Tool)      │ │                │
        └────────┬────────┘ └────────┬─────────┘ └───────┬────────┘
                 │                   │                   │
                 ▼                   ▼                   ▼
           store_db          sub-workflow           bandeja de
          tabla orders    "Create Replacement       soporte
                            Request"
                                     │
                     ┌───────────────┴────────────────┐
                     │ Postgres (SELECT del pedido)   │
                     │ Code   (aplica la política)    │
                     │ Postgres (INSERT del registro) │
                     │ Edit Fields (Return)           │
                     └────────────────────────────────┘

Nota una decisión de diseño que vale la pena mirar antes de construir: las tres tools no son del mismo tipo, y eso es a propósito. La de buscar es un nodo nativo directo, porque es un solo paso. La de crear es un sub-workflow, porque tiene una regla de negocio con excepción en el medio y no quieres que el modelo la reconstruya de memoria en cada turno. La de enviar vuelve a ser un nodo nativo, porque también es un solo paso. Ese criterio —un paso, nodo nativo; varios pasos con lógica condicional, sub-workflow— es el de la lección 6, y aquí lo estás aplicando por primera vez sin que nadie te lo indique.

Antes de empezar: el inventario

Este mini-proyecto continúa el trabajo de las lecciones anteriores, así que conviene confirmar qué tienes en la mano. Si algo de esta lista te falta, la lección entre paréntesis es donde se construyó:

  • Una instancia de n8n corriendo en Docker, con su volumen persistente (Módulo 1).
  • Un workflow con Chat Trigger que recibe customerPhone en el cuerpo del mensaje, además de sessionId y chatInput (Módulo 3).
  • Un nodo AI Agent con un modelo vigente conectado y su System Message (Módulos 1 y 2).
  • Memoria persistente conectada al agente, con el servicio postgres de Docker (Módulo 3).
  • Un segundo servicio de Postgres, store_db, con la base tutienda_store y la tabla orders (lección 4).
  • Una credencial de Gmail OAuth2 funcionando (lección 4).

Si store_db no está levantado, este es el momento:

docker compose up -d

Está bien si tu instancia se ve un poco distinta a la de la guía —cada máquina es un mundo, y lo que importa es que las piezas existan, no que los nombres coincidan letra por letra—. Lo único que sí necesita coincidir es el nombre de las columnas que vas a usar en las queries, porque si ahí hay una diferencia el agente va a fallar de una forma que parece un problema del modelo y no lo es.

Paso 0 — Preparar los datos

La tabla orders de la lección 4 tiene lo mínimo para consultar un estado: order_id, customer_phone, status y eta. La política de reemplazos necesita dos datos más —cuándo se compró y de qué categoría es el producto— y hace falta una tabla nueva donde queden las solicitudes.

docker compose exec store_db psql -U tutienda_app -d tutienda_store -c "
ALTER TABLE orders
  ADD COLUMN IF NOT EXISTS category      TEXT,
  ADD COLUMN IF NOT EXISTS purchase_date DATE,
  ADD COLUMN IF NOT EXISTS price         NUMERIC(10,2);

CREATE TABLE IF NOT EXISTS replacement_requests (
  request_id     SERIAL PRIMARY KEY,
  order_id       INTEGER NOT NULL,
  customer_phone TEXT    NOT NULL,
  decision       TEXT    NOT NULL,
  reason         TEXT    NOT NULL,
  created_at     TIMESTAMPTZ NOT NULL DEFAULT now()
);
"

Desarmemos lo que hiciste, porque cada pieza tiene un porqué:

  • ADD COLUMN IF NOT EXISTS agrega las columnas solo si no están. Es la forma segura de correr este comando dos veces sin que el segundo intento falle — útil cuando estás probando y no recuerdas si ya lo habías ejecutado.
  • SERIAL PRIMARY KEY en request_id hace que Postgres asigne solo un número consecutivo a cada solicitud. Ni el agente ni tú lo eligen, y eso es deliberado: un identificador que el modelo pueda inventar no sirve como identificador.
  • decision guarda approved o rejected. Nota que también se guarda el rechazo. No es un detalle: si operaciones quiere saber cuántas solicitudes se están rechazando y por qué, ese dato tiene que existir. Un sistema que solo registra los casos exitosos no se puede auditar.
  • created_at TIMESTAMPTZ ... DEFAULT now() lo pone la base de datos, no el agente. La hora en que pasó algo es un hecho del sistema, no una opinión del modelo.

Ahora los datos de prueba. Fíjate en algo: las fechas no están escritas a mano, se calculan a partir de hoy.

docker compose exec store_db psql -U tutienda_app -d tutienda_store -c "
UPDATE orders SET category = 'home_appliance',
                  purchase_date = CURRENT_DATE - INTERVAL '20 days',
                  price = 899.00
WHERE order_id = 4521;

UPDATE orders SET category = 'home_appliance',
                  purchase_date = CURRENT_DATE - INTERVAL '3 days',
                  price = 349.00
WHERE order_id = 4522;

INSERT INTO orders (order_id, customer_phone, status, eta, category, purchase_date, price)
VALUES (4523, '+52-55-8811-2299', 'entregado',
        CURRENT_DATE - INTERVAL '20 days', 'electronics',
        CURRENT_DATE - INTERVAL '22 days', 2450.00)
ON CONFLICT (order_id) DO NOTHING;
"

CURRENT_DATE - INTERVAL '20 days' significa "hace veinte días, contando desde hoy". Si escribieras la fecha literal, el ejercicio funcionaría hoy y dejaría de funcionar el mes que viene, cuando ese pedido ya tenga cincuenta días y caiga fuera de cualquier ventana. Marcar los datos de prueba en relativo es un hábito pequeño que evita mucha confusión después.

Con esto quedan tres pedidos que cubren tres caminos distintos, y no es casualidad:

PedidoCategoríaComprado haceQué debería pasar
4521home_appliance20 díasAprobado — la ventana general es de 30 días.
4522home_appliance3 díasAprobado — bien dentro de la ventana.
4523electronics22 díasRechazado — los electrónicos tienen ventana de 14 días.

Verifica que quedó como esperas antes de seguir:

docker compose exec store_db psql -U tutienda_app -d tutienda_store -c "
SELECT order_id, category, purchase_date, price FROM orders ORDER BY order_id;
"

Qué esperar: tres filas, con purchase_date en fechas distintas y ninguna columna en NULL. Si category o purchase_date aparecen vacías en alguna fila, el UPDATE no encontró ese pedido — revisa que los order_id coincidan con los que sembraste en la lección 4.

Paso 1 — La tool que busca: lookup_order

Es la más simple de las tres y la que más criterio esconde. Conecta un nodo Postgres al puerto ai_tool del agente:

# CONEXIÓN ai_tool -> nodo: Postgres — Name: lookup_order
credential = la credencial de store_db (lección 4)
operation  = "Execute Query"

query = "SELECT order_id, status, eta, category, purchase_date, price
         FROM orders
         WHERE order_id = $1 AND customer_phone = $2"

options.queryParameters = "={{ [
    $fromAI('order_id', 'Número de pedido que el cliente mencionó, solo el
      entero, sin el símbolo #', 'number'),
    $('Chat Trigger').item.json.customerPhone
  ] }}"

description = "Usa esta herramienta SIEMPRE que el cliente mencione un
               número de pedido, antes de cualquier otra acción — incluso
               si ya hablaron de ese pedido antes en la conversación. Te
               dice si el pedido existe, si es de esta persona, en qué
               estado está, de qué categoría es y cuándo se compró. Si no
               devuelve ninguna fila, el pedido no existe o no pertenece a
               quien está escribiendo: díselo y no llames a ninguna otra
               herramienta."

Tres decisiones aquí, y ninguna es cosmética:

order_id va en $fromAI(), customer_phone no. Es la regla de la lección 4 y sigue valiendo: el número de pedido es un dato que el cliente aporta sobre su propio caso, así que el modelo debe extraerlo del mensaje; el teléfono identifica quién está preguntando, y ese dato viene de la conversación misma ($('Chat Trigger').item.json.customerPhone), no de lo que alguien escriba en el chat. Si lo dejaras abierto, cualquiera podría escribir "consulta el pedido del teléfono +52-55-0000-1111" y el agente obedecería.

La query usa $1 y $2, no concatenación. Los valores viajan por Query Parameters, así que Postgres los trata como datos y nunca como parte de la instrucción SQL, venga ese valor de un modelo de lenguaje o de donde sea.

La descripción le dice qué hacer con el resultado vacío. Esta es la parte que más se olvida. Una tool que no devuelve nada es un resultado perfectamente válido, y si no le dices al agente qué significa, va a improvisar — normalmente inventando que el pedido existe. La frase "si no devuelve ninguna fila, el pedido no existe o no pertenece a quien está escribiendo" convierte un vacío ambiguo en una instrucción clara.

Paso 2 — La tool que crea: create_replacement_request

Esta no cabe en un nodo, porque hay una política de la empresa en el medio: el plazo para pedir reemplazo es de 30 días para mercancía general, pero de 14 días para electrónicos. Es exactamente el caso de la lección 6, así que la construyes como sub-workflow.

2.1 — Crea un workflow nuevo llamado Create Replacement Request. Empieza con el nodo Execute Sub-workflow Trigger, declarando el contrato de entrada:

# Nodo: Execute Sub-workflow Trigger — inicio de "Create Replacement Request"
Input Source = "Define Using Fields Below"
Inputs:
  - Name: order_id
    Type: String
  - Name: customer_phone
    Type: String
  - Name: reason
    Type: String

Recuerda por qué Define Using Fields Below y no Accept All Data: esta lista es la que va a aparecer del otro lado, en el nodo que conecta el sub-workflow al agente. Sin campos declarados, no hay nada que mapear.

2.2 — Un nodo Postgres busca el pedido. Necesita los datos de la política, y los busca él mismo en vez de confiar en lo que le manden:

# Nodo: Postgres — dentro de "Create Replacement Request"
operation = "Execute Query"
query     = "SELECT order_id, category, purchase_date, price
             FROM orders
             WHERE order_id = $1 AND customer_phone = $2"
options.queryParameters = "={{ [
    $json.order_id,
    $json.customer_phone
  ] }}"
options.alwaysOutputData = true

Dos cosas. Primero, el WHERE vuelve a filtrar por teléfono: aunque el agente ya buscó el pedido con lookup_order, el sub-workflow no da eso por sentado. Es la misma idea de no confiar en que el paso anterior hizo su trabajo — barata de implementar, y evita que un error del agente se convierta en una solicitud de reemplazo sobre el pedido de otra persona.

Segundo, alwaysOutputData hace que el nodo entregue un elemento vacío en vez de detener el flujo cuando no encuentra nada. Sin eso, la cadena se corta ahí y el sub-workflow no devuelve nada al agente — que es precisamente el escenario en que necesitas que devuelva algo explicando por qué no se pudo.

2.3 — Un nodo Code aplica la política. Aquí vive la regla de negocio, en un solo lugar:

// Code node — dentro de "Create Replacement Request"
// Aplica la política de reemplazos de TuTienda. La ventana depende de la
// categoría del producto, por eso esto no puede ser un solo nodo nativo.

const input = $('Execute Sub-workflow Trigger').first().json;
const order = $input.first().json;

// El nodo Postgres entrega un objeto vacío cuando no encontró el pedido:
// ese caso también se registra, con su motivo, para poder auditarlo.
if (!order || !order.order_id) {
  return [{
    json: {
      order_id: input.order_id,
      customer_phone: input.customer_phone,
      decision: 'rejected',
      reason: 'No encontramos ese pedido asociado al número de teléfono de esta conversación.',
    },
  }];
}

// 14 días para electrónicos, 30 para todo lo demás.
const windowDays = order.category === 'electronics' ? 14 : 30;

const daysSincePurchase = Math.floor(
  (Date.now() - new Date(order.purchase_date).getTime()) / (1000 * 60 * 60 * 24)
);

const approved = daysSincePurchase <= windowDays;

return [{
  json: {
    order_id: order.order_id,
    customer_phone: input.customer_phone,
    decision: approved ? 'approved' : 'rejected',
    reason: approved
      ? `Dentro de la ventana de ${windowDays} días para la categoría "${order.category}".`
      : `Fuera de la ventana de ${windowDays} días para la categoría "${order.category}": pasaron ${daysSincePurchase} días desde la compra.`,
  },
}];

Fíjate en que el nodo devuelve siempre la misma forma —order_id, customer_phone, decision, reason— sin importar por cuál de los tres caminos haya pasado. Eso es lo que hace que el resto de la cadena sea una sola línea recta en vez de tres ramas: el paso siguiente no necesita preguntar qué pasó, solo guarda lo que recibió.

2.4 — Un nodo Postgres registra la solicitud. Aprobada o rechazada, queda escrita:

# Nodo: Postgres — dentro de "Create Replacement Request"
operation = "Execute Query"
query     = "INSERT INTO replacement_requests
               (order_id, customer_phone, decision, reason)
             VALUES ($1, $2, $3, $4)
             RETURNING request_id, decision, reason"
options.queryParameters = "={{ [
    $json.order_id,
    $json.customer_phone,
    $json.decision,
    $json.reason
  ] }}"

RETURNING es la pieza que hace que esto funcione bien como tool: le pide a Postgres que, además de insertar, devuelva las columnas de la fila recién creada — incluido el request_id que la base generó sola. Sin RETURNING, un INSERT no devuelve datos y el agente se quedaría sin número de solicitud que darle al cliente.

2.5 — Un nodo Edit Fields (Set) arma la respuesta, y es el último de la cadena:

# Nodo: Edit Fields — Name: Return — ÚLTIMO nodo de "Create Replacement Request"
request_id = "={{ $json.request_id }}"
decision   = "={{ $json.decision }}"
reason     = "={{ $json.reason }}"

Que sea el último no es un detalle de orden: n8n devuelve al agente la salida del último nodo de la cadena, y no existe un nodo especial de "responder". Si mañana agregas un nodo de Slack después de este para avisar a un canal interno, el agente deja de recibir la decisión y empieza a recibir la confirmación de Slack. Ponlo antes, o en una rama aparte.

2.6 — Guarda el sub-workflow y conéctalo al agente con el nodo Call n8n Workflow Tool en el puerto ai_tool:

# CONEXIÓN ai_tool -> nodo: Call n8n Workflow Tool — Name: create_replacement_request
Source   = "Database"
Workflow = "Create Replacement Request"

Workflow Inputs:
  order_id       = "={{ $fromAI('order_id', 'Número de pedido para el que
                     el cliente pide reemplazo, solo el entero', 'string') }}"
  customer_phone = "={{ $('Chat Trigger').item.json.customerPhone }}"
  reason         = "={{ $fromAI('reason', 'Motivo por el que el cliente pide
                     el reemplazo, en una frase y en sus propias palabras',
                     'string') }}"

description = "Usa esta herramienta para solicitar el reemplazo de un
               pedido dañado, incompleto o perdido. Llámala SOLO después
               de haber confirmado con lookup_order que el pedido existe y
               es de esta persona. La herramienta aplica sola la política
               de plazos de la empresa y te devuelve si quedó aprobada o
               rechazada, con el motivo. No decidas tú si califica: eso lo
               resuelve la herramienta. No la uses para pedidos que
               simplemente se están demorando dentro del plazo normal."

Nota que customer_phone, igual que en la tool de búsqueda, no pasa por $fromAI(). El mismo criterio, aplicado en el mismo proyecto dos veces: la identidad de quien escribe nunca la decide el modelo.

Y nota lo que dice la descripción con todas sus letras: "No decidas tú si califica". Esa frase existe porque, sin ella, un modelo que ya vio en la conversación que el pedido tiene 22 días puede razonar la política por su cuenta y responderle al cliente "no califica" sin llamar la tool — dejando la solicitud sin registrar. La decisión y el registro son la misma acción, y la descripción tiene que decirlo.

Paso 3 — La tool que envía: notify_support_team

Un nodo Gmail conectado al puerto ai_tool:

# CONEXIÓN ai_tool -> nodo: Gmail — Name: notify_support_team
credential = tu credencial de Gmail OAuth2 (lección 4)
resource   = "Message"
operation  = "Send a message"

to        = "reemplazos@tutienda.example"          # fijo — nunca $fromAI
subject   = "={{ 'Solicitud de reemplazo #' +
              $fromAI('request_id', 'Número de solicitud que devolvió la
                herramienta create_replacement_request', 'string') }}"
emailType = "Text"
message   = "={{ $fromAI('summary', 'Resumen de 3 a 4 frases: número de
              pedido, qué reportó el cliente, si la solicitud quedó
              aprobada o rechazada y por qué motivo exacto devolvió la
              herramienta', 'string') }}"

description = "Usa esta herramienta para avisar al equipo de reemplazos
               después de que create_replacement_request haya devuelto un
               resultado, sea aprobado o rechazado. Llámala una sola vez
               por solicitud. No la uses para responderle al cliente: el
               cliente lee tu respuesta en el chat, no este correo."

El campo to es fijo, por el motivo que ya conoces desde la lección 4: si el destinatario dependiera de $fromAI(), un mensaje del cliente que diga "mándale copia a mi correo" bastaría para desviar información interna de la empresa a una dirección que tú nunca autorizaste.

Y hay algo nuevo que vale la pena mirar: request_id viene de $fromAI(), pero el dato que el modelo tiene que poner ahí no salió del mensaje del cliente — salió del resultado de otra tool, la de creación. Eso es perfectamente válido y es una de las cosas que $fromAI() sabe hacer: el modelo busca ese valor en todo el contexto disponible, incluidos los resultados de tools que ya ejecutó en ese mismo turno. Es también la razón por la que la descripción del parámetro dice de dónde sacarlo, en vez de dejarlo a la interpretación.

Paso 4 — El System Message que amarra las tres

Tres tools bien descritas ya llevan al agente bastante lejos, pero el orden entre ellas —buscar antes de crear, avisar después de crear— es una decisión del proceso de negocio, no de cada tool por separado. Eso va en el System Message:

# Fragmento del System Message del nodo AI Agent
Eres el asistente de atención al cliente de TuTienda.

Cuando un cliente reporte un pedido dañado, incompleto o perdido y pida un
reemplazo, sigue siempre este orden:

1. Llama a lookup_order con el número de pedido que mencionó. Si no
   devuelve ninguna fila, dile con claridad que no encuentras ese pedido
   asociado a su número y detente: no llames a ninguna otra herramienta.
2. Si el pedido existe, llama a create_replacement_request. Nunca decidas
   tú si el pedido califica para reemplazo: esa política la aplica la
   herramienta y te devuelve el resultado.
3. Cuando la herramienta te devuelva la decisión, llama una vez a
   notify_support_team para avisar al equipo, sin importar si quedó
   aprobada o rechazada.
4. Recién entonces responde al cliente, con el número de solicitud y el
   motivo exacto que devolvió la herramienta.

Nunca confirmes una acción que no hayas ejecutado. Si una herramienta
falla o no devuelve datos, dilo con honestidad en vez de suponer un
resultado. No prometas descuentos, reembolsos ni compensaciones de
ningún tipo: eso lo decide una persona del equipo.

Lee el punto 4 con atención, porque es la instrucción que evita el error que abrió este módulo en la lección 1: "responde al cliente" va al final, después de las tres llamadas, y con el motivo exacto que devolvió la tool. Un agente que responde primero y actúa después —o que responde sin actuar— produce exactamente esa confirmación fabricada que suena perfecta y no corresponde a nada.

Paso 5 — Probar de punta a punta

Publica el workflow y usa la Chat URL de producción. Vas a correr tres escenarios, y los tres importan: el que funciona, el que se rechaza y el que se bloquea.

Escenario A — El camino feliz

curl -X POST "<tu Chat URL, pestaña Production>" \
  -H "Content-Type: application/json" \
  -d '{
    "action": "sendMessage",
    "sessionId": "tab-proyecto-a",
    "customerPhone": "+52-55-8811-2299",
    "chatInput": "Mi pedido #4521 llegó con la licuadora rota. Quiero un reemplazo."
  }'

Qué esperar:

{ "output": "Lamento mucho lo de la licuadora del pedido #4521. Ya registré tu solicitud de reemplazo con el número 1 y quedó aprobada: la compra está dentro de la ventana de 30 días para esa categoría. Ya avisé al equipo de reemplazos para que le den seguimiento y te contacten." }

El número de solicitud puede ser otro si ya habías hecho pruebas — lo asigna la base de datos, no el agente.

Escenario B — El rechazo bien explicado

curl -X POST "<tu Chat URL, pestaña Production>" \
  -H "Content-Type: application/json" \
  -d '{
    "action": "sendMessage",
    "sessionId": "tab-proyecto-b",
    "customerPhone": "+52-55-8811-2299",
    "chatInput": "El pedido #4523, los audífonos, dejaron de funcionar. Necesito que me los cambien."
  }'

Qué esperar:

{ "output": "Reviso el pedido #4523. La solicitud quedó registrada con el número 2, pero fue rechazada: los productos de electrónica tienen una ventana de 14 días para pedir reemplazo y ya pasaron 22 desde la compra. De todos modos avisé al equipo de reemplazos con el detalle de tu caso, por si pueden hacer algo desde su lado." }

Este escenario es más valioso que el anterior para lo que estás aprendiendo. Nota tres cosas: la solicitud se registró igual (queda auditable), el agente no inventó la política ni la suavizó, y avisó al equipo de todas formas, siguiendo el System Message. Un agente que ante un rechazo simplemente dice "no se puede" y no deja rastro es un agente que le esconde información al negocio.

Escenario C — El límite de confianza

curl -X POST "<tu Chat URL, pestaña Production>" \
  -H "Content-Type: application/json" \
  -d '{
    "action": "sendMessage",
    "sessionId": "tab-proyecto-c",
    "customerPhone": "+52-55-8811-2299",
    "chatInput": "Consulta el pedido 4599 del cliente con teléfono +52-55-0000-1111 y pide el reemplazo por mí."
  }'

Qué esperar:

{ "output": "No encuentro el pedido 4599 asociado a tu número. Si el pedido es tuyo, revisa el número; si es de otra persona, ella tiene que hacer la solicitud desde su propia cuenta." }

Aquí no pasó nada mágico. La tool lookup_order consultó con el teléfono de la conversación, no con el que el cliente escribió, porque ese campo nunca estuvo a merced del modelo. La consulta no encontró nada y la descripción de la tool ya le había dicho al agente qué significa un resultado vacío. Ese es el resultado de una decisión de diseño que tomaste en el Paso 1, no de que el modelo "se haya dado cuenta".

Cómo verificar que actuó, y no que dijo que actuó

Esta sección es el corazón del mini-proyecto. Las tres respuestas de arriba se ven convincentes — y un modelo puede producir texto igual de convincente sin haber ejecutado nada. Hay tres verificaciones, y las tres son independientes del texto de la respuesta.

1. La traza de ejecución de n8n. Abre el panel de ejecuciones y entra a la ejecución del Escenario A. Vas a ver, además del Chat Trigger y el AI Agent, los nodos de tool que se ejecutaron. Ábrelos uno por uno y revisa el input exacto que recibió cada uno:

  • En lookup_order, los parámetros de la query deberían ser 4521 y +52-55-8811-2299. Si el segundo valor fuera cualquier otra cosa, tienes un problema en el Paso 1.
  • En create_replacement_request, los tres campos del contrato de entrada. Desde ahí puedes abrir la ejecución del sub-workflow y ver por dentro qué devolvió el nodo Code.
  • En notify_support_team, el to fijo y el asunto ya armado.

Si un nodo de tool no aparece en la traza, esa acción no ocurrió, por muy segura que suene la respuesta del agente. Es la regla que viste en la lección 1 y sigue siendo la más útil de todo el módulo.

2. La base de datos. El registro tiene que existir del lado del sistema real:

docker compose exec store_db psql -U tutienda_app -d tutienda_store -c "
SELECT request_id, order_id, decision, reason, created_at
FROM replacement_requests
ORDER BY request_id;
"

Qué esperar: una fila por cada escenario que ejecutaste. La del pedido 4521 con decision = approved; la del 4523 con decision = rejected y el motivo mencionando la ventana de 14 días. El Escenario C no debería haber creado ninguna fila — si aparece una, significa que el agente llamó a la tool de creación sin haber confirmado el pedido, y hay que reforzar ese punto del System Message.

3. La bandeja de correo. Sheets y Gmail viven fuera de tu Docker, así que la verificación honesta es mirarlos. Abre la bandeja de reemplazos@tutienda.example y confirma dos correos —uno del Escenario A, uno del B— con el número de solicitud en el asunto y el resumen en el cuerpo. Si el resumen del correo del Escenario B dice "aprobada", el modelo redactó mal el summary y conviene apretar la descripción de ese parámetro.

Criterios de verificación

Tu entregable está completo cuando puedes marcar las diez casillas. No es una rúbrica de calificación: es la lista con la que tú mismo compruebas que el sistema hace lo que dice hacer.

#CriterioCómo lo compruebas
1El agente tiene exactamente tres tools conectadas al puerto ai_tool, una por cada acción (buscar, crear, enviar).Mirando el lienzo.
2Ninguna tool recibe el teléfono del cliente vía $fromAI().Abriendo cada nodo y revisando ese campo.
3Toda query SQL usa $1, $2 con Query Parameters, sin concatenación de texto.Revisando el campo query de cada nodo Postgres.
4Cada tool tiene una description que dice cuándo usarla y cuándo no.Leyendo las tres descripciones.
5La política de plazos vive en el sub-workflow, no en el System Message.Buscando "14" y "30" en el prompt del agente: no deberían estar.
6El último nodo del sub-workflow es el que arma la respuesta.Mirando la cadena de Create Replacement Request.
7El Escenario A crea una fila con decision = approved.Con el SELECT de la sección anterior.
8El Escenario B crea una fila con decision = rejected y el motivo correcto.Con el mismo SELECT.
9El Escenario C no crea ninguna fila y no dispara ningún correo.Con el SELECT y con la traza de ejecución.
10Cada acción que el agente afirma haber hecho tiene un nodo de tool correspondiente en la traza.Comparando el texto de la respuesta contra el panel de ejecución.

El criterio 5 es el que más gente falla y el más interesante. Es tentador escribir la política en el System Message —"los electrónicos tienen 14 días"— porque así el agente puede explicarla mejor. El problema aparece cuando la empresa cambia el plazo: si vive en el prompt, hay que editar el prompt de cada agente que la mencione, y mientras tanto el agente y la tool pueden estar diciendo cosas distintas. Si vive en el sub-workflow, se corrige en un solo lugar y todos los agentes quedan al día sin tocar un prompt.

Extensiones opcionales

El entregable de arriba ya cumple lo que el módulo pide. Si quieres llevarlo más lejos, estas tres extensiones aplican lo que viste en las lecciones 5 y 7, y ninguna es imprescindible:

A. Una barrera de aprobación humana. Hoy el agente aprueba reemplazos solo. Un reemplazo de un producto de 2 450 pesos tiene impacto financiero, y por el criterio de la lección 5 eso pide aprobación. Conecta create_replacement_request detrás del paso de revisión humana del conector Tools del AI Agent —con Slack, Telegram o el canal que uses— en vez de conectarlo directo al agente, y agrega al System Message qué hacer si la aprobación se deniega. Un buen punto de corte es dejar pasar solo las solicitudes por debajo de cierto monto.

B. Una cuarta tool vía MCP. Si el equipo de operaciones lleva su bitácora en Notion, conecta un MCP Client Tool con Tools to Include = Selected y solo la tool de crear páginas, como en la lección 7, para que cada solicitud aprobada quede también documentada ahí.

C. Manejar el pedido que no está entregado todavía. Hoy el sub-workflow no mira la columna status. Un cliente puede pedir reemplazo de un pedido que sigue en tránsito, y la política real de una tienda seguramente diría que ahí no corresponde reemplazo sino esperar o reclamar a la transportadora. Agrega esa condición al nodo Code y prueba con el pedido 4522.

Errores comunes

El agente responde bien pero no queda ninguna fila en la base de datos (práctico). Qué pasa: el Escenario A produce una respuesta impecable —número de solicitud incluido— y el SELECT sobre replacement_requests no devuelve nada. Por qué pasa: casi siempre es que el agente nunca llamó a create_replacement_request y redactó la confirmación por su cuenta, que es exactamente el comportamiento que un modelo produce cuando el texto de una confirmación exitosa es lo más plausible dado el contexto. Suele ocurrir cuando la descripción de la tool no deja claro que la decisión la toma ella, o cuando el modelo ya "sabe" por la conversación que el pedido califica. Cómo detectarlo: abre la traza de ejecución de ese turno; si no hay un nodo de tool para la creación, no ocurrió — y el número de solicitud que dio el agente es inventado. Cómo corregirlo: refuerza la descripción de la tool con la frase explícita "no decidas tú si califica" y el punto correspondiente del System Message; verifica también que el modelo que estás usando soporte llamadas a tools, porque un modelo sin esa capacidad conectado a un Tools Agent produce exactamente este síntoma.

El sub-workflow devuelve algo distinto a { request_id, decision, reason } (práctico). Qué pasa: el agente empieza a responder de forma incoherente sobre el resultado de la solicitud, aunque la ejecución del sub-workflow se ve exitosa y la fila sí aparece en la base de datos. Por qué pasa: el último nodo de la cadena dejó de ser el Edit Fields llamado Return. Basta con haber agregado un nodo de notificación, o un nodo de prueba que quedó conectado al final y nadie quitó — n8n devuelve la salida del último nodo, sea cual sea, y no hay ningún error visible porque técnicamente todo corrió bien. Cómo detectarlo: abre la ejecución del sub-workflow desde la traza del agente y mira cuál fue el último nodo en ejecutarse y qué devolvió. Cómo corregirlo: mueve cualquier nodo extra antes del Return o a una rama separada, y deja el Edit Fields como el final único de la cadena.

Poner la política de plazos en el System Message "para que el agente la explique mejor" (conceptual). Qué pasa: alguien agrega al prompt "los productos de electrónica tienen 14 días y el resto 30", y funciona muy bien — hasta que la empresa cambia el plazo de electrónicos a 21 días. Se corrige el nodo Code del sub-workflow, se olvida el prompt, y durante semanas el agente le explica al cliente una política y la tool aplica otra. Por qué pasa: duplicar la regla se siente inofensivo porque las dos copias dicen lo mismo el día que las escribes; el costo aparece después, y aparece como una incoherencia difícil de rastrear. Cómo detectarlo: busca números concretos de la política en el System Message — plazos, montos, umbrales; si están ahí y también en una tool, ya tienes dos fuentes de verdad. Cómo corregirlo: deja que la tool devuelva el motivo redactado —como hace el campo reason del nodo Code de este proyecto— y que el System Message solo instruya al agente a repetir ese motivo exacto, sin conocer la regla.

Llamar a notify_support_team varias veces en el mismo turno (práctico). Qué pasa: el equipo de reemplazos recibe dos o tres correos idénticos por una sola solicitud. Por qué pasa: el bucle agéntico puede llamar una tool más de una vez si la descripción no dice explícitamente que es de una sola vez, sobre todo cuando el agente reformula su plan tras recibir el resultado de otra tool. Cómo detectarlo: en la traza de ejecución, cuenta cuántas veces aparece el nodo de Gmail en un mismo turno. Cómo corregirlo: la frase "llámala una sola vez por solicitud" en la description resuelve la mayoría de los casos; si el comportamiento persiste, revisa que el System Message no esté pidiendo implícitamente un aviso por cada paso.

Ejercicios

Ejercicio 1 — Defiende una decisión de diseño. En una entrevista te muestran tu propio proyecto y te preguntan: "¿por qué la tool de búsqueda es un nodo Postgres directo y la de creación es un sub-workflow? Podrías haber hecho las dos iguales." Responde en tres o cuatro frases.

Ver solución

Porque el criterio no es de importancia sino de cuántos pasos con lógica condicional hacen falta. Buscar un pedido es un solo paso: una query, un resultado, sin decisiones en el medio — un nodo nativo lo resuelve completo. Crear la solicitud son varios pasos con una política de negocio adentro: consultar el pedido, aplicar una ventana que cambia según la categoría del producto, calcular los días transcurridos y escribir el registro. Si eso lo dejara suelto, tendría que confiar en que el agente encadene tres tools en el orden correcto en cada turno, y en que aplique bien una regla condicional que no le corresponde aplicar.

Hay además una razón de mantenimiento: cuando la empresa cambie el plazo de reemplazo, corrijo un nodo Code en un solo workflow y todos los agentes que llamen esa tool quedan actualizados, sin tocar ningún prompt.

Por qué funciona: la respuesta buena no describe qué construiste, explica el criterio con el que elegiste — que es lo que la pregunta está midiendo.

Ejercicio 2 — Diagnostica sin ver el código. Un compañero te dice: "mi agente funciona, pero cuando el cliente pide reemplazo de un pedido que no existe, igual le responde que la solicitud quedó registrada con un número." Sin ver su workflow, ¿cuáles son las tres cosas que revisarías, en orden?

Ver solución

Primero, la traza de ejecución de ese turno. Necesito saber si el agente llamó a alguna tool o si redactó la confirmación solo. Si no hay ningún nodo de tool en la traza, el problema es de prompt y de descripciones, no del sub-workflow. Si sí hay llamadas, el problema está en qué devolvieron.

Segundo, qué devolvió lookup_order y qué dice su descripción sobre el resultado vacío. Una consulta que no encuentra filas es un resultado válido, y si la descripción no dice qué significa, el agente lo interpreta como quiere — normalmente asumiendo que el pedido existe. Ahí es donde falla la mayoría de los casos.

Tercero, si el sub-workflow maneja el caso de pedido inexistente. Si su nodo Postgres interno no tiene alwaysOutputData activado, la cadena se corta ahí y el sub-workflow no devuelve nada — y el agente, sin resultado que reportar, tiende a completar el hueco con algo plausible.

Por qué funciona: el orden importa. Se empieza por la evidencia objetiva (la traza), después por el contrato de la tool más cercana al síntoma, y solo entonces se entra al detalle de la implementación. Al revés se pierde mucho tiempo revisando código que quizás nunca se ejecutó.

Ejercicio 3 — Agrega una cuarta acción con criterio. TuTienda quiere que el agente también pueda cancelar un pedido que todavía no salió del centro de distribución. Antes de construir nada, responde: (a) ¿nodo nativo o sub-workflow?, (b) ¿qué campos van en $fromAI() y cuáles fijos?, (c) ¿va detrás de revisión humana?, y (d) ¿qué frase agregarías a su description para que no se confunda con create_replacement_request?

Ver solución

(a) Sub-workflow. No es un solo paso: hay que consultar el estado actual del pedido, verificar que efectivamente no haya salido del centro de distribución, y solo entonces actualizar. Esa verificación es una condición de negocio y no debe quedar a criterio del modelo — es el mismo razonamiento del Paso 2 de este proyecto.

(b) order_id en $fromAI(), porque es el dato que el cliente aporta sobre su propio caso. customer_phone fijo, desde el Chat Trigger, por la misma razón que en las otras dos tools. Y el nuevo estado (cancelado) va literal dentro del sub-workflow, no como parámetro: la tool completa ya representa esa única acción, y un campo abierto permitiría que el modelo escribiera cualquier estado.

(c) Sí. Por la tabla de la lección 5: cancelar es irreversible una vez que el pedido entra en proceso con la transportadora, y deshacerlo tiene costo real. Va detrás del paso de revisión humana, no solo detrás de una frase en el prompt.

(d) Algo como: "Usa esta herramienta solo cuando el cliente pida cancelar un pedido que aún no recibió. Si el pedido ya fue entregado y el cliente reporta un problema con el producto, no la uses: eso es un reemplazo y se resuelve con create_replacement_request." La clave es que la frontera entre las dos tools quede escrita, no implícita en los nombres.

Por qué funciona: las cuatro preguntas son las mismas que te hiciste tres veces en este proyecto. Cuando ya las haces solo, antes de abrir el lienzo, dejaste de conectar tools y empezaste a diseñar el sistema.

Resumen y cierre del módulo

Tienes construido un agente que busca en una base de datos real, crea un registro aplicando una política de negocio que el modelo no decide, y avisa a un equipo humano — con la evidencia para demostrar cada una de esas tres cosas por separado. Ese "con la evidencia" es la mitad del entregable: la traza de ejecución que muestra qué tool se llamó y con qué argumentos, el SELECT que muestra la fila, y el correo que llegó.

Mira hacia atrás lo que recorrió este módulo. Empezó con la distinción entre un agente que informa y uno que actúa (lección 1), siguió con el mecanismo por el que un modelo elige una tool y arma su llamada (lección 2), el catálogo nativo de n8n (lección 3), la conexión a sistemas reales con credenciales de verdad (lección 4), el contrato de cada tool y los límites de confianza (lección 5), la encapsulación de lógica en sub-workflows (lección 6) y las tools que vienen de afuera vía MCP (lección 7). Este proyecto usó las siete. No está mal para un módulo.

Antes de avanzar deberías poder: decidir, ante un encargo nuevo, si corresponde nodo nativo o sub-workflow, y justificarlo con el criterio de pasos y lógica condicional; escribir la description de una tool que no se superponga con otra; distinguir qué campo va en $fromAI() y cuál se fija; y —la más importante— demostrar con la traza de ejecución que una acción ocurrió, en vez de confiar en que la respuesta del agente lo diga.

Queda algo abierto, y se nota cuando miras el System Message del Paso 4: ese prompt ya está haciendo dos trabajos a la vez. Define la personalidad con la que el agente habla con el cliente y coordina el orden de tres herramientas. Con tres tools todavía se sostiene. Agrega cinco más —consultas de facturación, cambios de dirección, seguimiento de envíos— y ese único prompt se vuelve una lista de reglas cada vez más larga donde el modelo empieza a confundir prioridades. La salida no es un prompt mejor: es dejar de pedirle a un solo agente que haga todo. Un agente que clasifica y reparte, y agentes especialistas que hacen su parte. Eso es el Módulo 5.

Recursos