Un agente de IA no es un chatbot. Es un sistema que recibe un estímulo externo, razona sobre él, toma decisiones y devuelve un resultado que otro sistema puede consumir. En este post construimos ese sistema pieza a pieza: un webhook en Next.js 16 recibe la solicitud, la valida con Zod, la delega a un flujo n8n que llama a Claude, valida la respuesta del modelo y devuelve el resultado al origen. Sin magia, sin cajas negras — solo tres piezas bien conectadas.
Arquitectura del pipeline
El pipeline tiene cuatro responsabilidades separadas que conviene mantener desacopladas:
- Entrada y validación (Next.js): el route handler recibe la petición HTTP, valida el cuerpo con Zod y envía el payload al webhook de n8n. Es la puerta de entrada — nada entra sin pasar por el schema.
- Orquestación (n8n): el flujo recibe el evento, construye la petición a la API de Claude, la ejecuta, transforma la respuesta y la devuelve al caller. n8n gestiona el estado, los reintentos y la observabilidad visual del flujo.
- Razonamiento (Claude): el modelo recibe el prompt con el contenido de la tarea, razona y devuelve la salida. Con tool use garantizamos que la salida tenga el schema exacto que esperamos.
- Validación de salida (Zod en el nodo Code): antes de devolver el resultado al caller, el flujo valida que la respuesta del modelo cumple el schema esperado. Si no cumple, reintenta o devuelve error.
El flujo completo: POST /api/analyze (Next.js) → POST n8n-webhook → HTTP Request (Claude API) → Code node (validar + transformar) → respond-to-webhook → Response (Next.js).
El webhook en Next.js
El route handler de Next.js 16 actúa como proxy inteligente: valida la entrada, añade autenticación hacia n8n y espera la respuesta síncrona.
// app/api/analyze/route.ts
import { z } from "zod";
import { NextResponse } from "next/server";
const AnalyzeSchema = z.object({
content: z.string().min(10).max(4000),
type: z.enum(["classify", "summarize", "extract"]),
language: z.enum(["es", "en"]).default("es"),
});
export async function POST(req: Request) {
// 1. Validar el body con Zod
let body: unknown;
try {
body = await req.json();
} catch {
return NextResponse.json({ error: "Invalid JSON" }, { status: 400 });
}
const parsed = AnalyzeSchema.safeParse(body);
if (!parsed.success) {
return NextResponse.json(
{ error: "Validation failed", issues: parsed.error.issues },
{ status: 422 }
);
}
const { content, type, language } = parsed.data;
// 2. Delegar al webhook de n8n (llamada síncrona — n8n responde en el mismo request)
const n8nWebhookUrl = process.env.N8N_WEBHOOK_URL;
if (!n8nWebhookUrl) {
return NextResponse.json({ error: "Webhook not configured" }, { status: 500 });
}
let n8nResponse: Response;
try {
n8nResponse = await fetch(n8nWebhookUrl, {
method: "POST",
headers: {
"content-type": "application/json",
// Autenticación básica opcional entre Next.js y n8n
"x-webhook-secret": process.env.N8N_WEBHOOK_SECRET ?? "",
},
body: JSON.stringify({ content, type, language }),
signal: AbortSignal.timeout(29_000), // n8n tiene límite de 30 s
});
} catch (err) {
const msg = err instanceof Error ? err.message : "Unknown error";
return NextResponse.json({ error: "n8n unreachable", detail: msg }, { status: 502 });
}
if (!n8nResponse.ok) {
const text = await n8nResponse.text();
return NextResponse.json(
{ error: "n8n error", detail: text },
{ status: n8nResponse.status }
);
}
// 3. Devolver la respuesta de n8n directamente al cliente
const result = await n8nResponse.json();
return NextResponse.json(result);
}
Puntos clave: el AbortSignal.timeout evita que el route handler quede colgado si n8n tarda más de lo esperado. El secret compartido (x-webhook-secret) impide que cualquiera llame al webhook de n8n directamente. El schema Zod es la única fuente de verdad sobre qué acepta el endpoint.
El flujo en n8n
El flujo tiene tres nodos principales más la configuración de credenciales. No necesitas nada más para un pipeline funcional.
Nodo 1 — Webhook: activa el flujo con Method POST. En "Response Mode" selecciona "Last Node" — así n8n espera a que el flujo complete y devuelve la respuesta del último nodo al caller síncrono. Configura el path (/analyze) y añade un Header Auth para verificar el x-webhook-secret.
Nodo 2 — HTTP Request (Claude): llama a la API de Anthropic. Configuración:
- Method: POST
- URL:
https://api.anthropic.com/v1/messages - Authentication: Generic Credential Type → Header Auth → Name:
x-api-key, Value: credencialANTHROPIC_API_KEYguardada en n8n. - Headers adicionales:
anthropic-version: 2023-06-01,content-type: application/json. - Body: JSON con el objeto de messages. Usa expresiones n8n para interpolar el contenido del Webhook:
{{ $json.content }}.
El body del HTTP Request, con tool use para forzar salida estructurada:
{
"model": "claude-sonnet-4-6",
"max_tokens": 1024,
"tools": [
{
"name": "analyze_content",
"description": "Analiza el contenido y devuelve el resultado estructurado.",
"input_schema": {
"type": "object",
"properties": {
"category": { "type": "string" },
"summary": { "type": "string" },
"confidence": { "type": "number", "minimum": 0, "maximum": 1 }
},
"required": ["category", "summary", "confidence"]
}
}
],
"tool_choice": { "type": "tool", "name": "analyze_content" },
"messages": [
{
"role": "user",
"content": "Analiza este contenido: {{ $('Webhook').item.json.content }}"
}
]
}Nodo 3 — Code (JavaScript): extrae y valida la salida de Claude antes de devolverla.
Validar la salida del modelo
Los LLMs son probabilísticos. Aunque tool use reduce enormemente los fallos de formato, una capa de validación explícita es la diferencia entre un pipeline frágil y uno robusto. En el nodo Code de n8n puedes escribir JavaScript puro — incluyendo validación con un schema manual o importando librerías si tu instancia lo permite.
En el route handler de Next.js (o en un endpoint separado de validación), el schema Zod para la salida de Claude es:
import { z } from "zod";
const ClaudeOutputSchema = z.object({
category: z.string().min(1),
summary: z.string().min(1),
confidence: z.number().min(0).max(1),
});
type ClaudeOutput = z.infer<typeof ClaudeOutputSchema>;
function parseClaudeToolResult(apiResponse: unknown): ClaudeOutput {
// La respuesta viene en content[0].input cuando usamos tool_choice forzado
const raw = (apiResponse as Record<string, unknown>);
const content = raw?.content as Array<Record<string, unknown>> | undefined;
if (!Array.isArray(content) || content.length === 0) {
throw new Error("Claude response has no content array");
}
const toolUse = content.find((c) => c.type === "tool_use");
if (!toolUse) {
throw new Error("No tool_use block in Claude response");
}
const parsed = ClaudeOutputSchema.safeParse(toolUse.input);
if (!parsed.success) {
throw new Error(`Claude output invalid: ${parsed.error.message}`);
}
return parsed.data;
}En el nodo Code de n8n, la misma lógica en JavaScript:
// Nodo Code en n8n — extrae y valida la salida de Claude
const claudeResponse = $json; // respuesta completa del HTTP Request
const content = claudeResponse?.content ?? [];
const toolUse = content.find((c) => c.type === "tool_use");
if (!toolUse || !toolUse.input) {
throw new Error("No tool_use block found in Claude response");
}
const { category, summary, confidence } = toolUse.input;
// Validación básica (sin Zod, que no está disponible por defecto en n8n)
if (!category || typeof category !== "string") throw new Error("Missing category");
if (!summary || typeof summary !== "string") throw new Error("Missing summary");
if (typeof confidence !== "number") throw new Error("Missing confidence");
return [{ json: { category, summary, confidence } }];Si la validación falla, el nodo lanza un error que n8n puede capturar con un nodo Error Trigger o con el conector de error del propio nodo. Esto activa tu flujo de manejo de fallos sin que el error quede silencioso.
Devolver el resultado
Con "Response Mode: Last Node" en el Webhook, n8n devuelve automáticamente el output del último nodo como respuesta HTTP. El nodo Code es el último, así que el JSON que devuelve ({ category, summary, confidence }) llega directamente al route handler de Next.js que hizo el fetch.
Para persistir el resultado (por ejemplo, guardar en base de datos antes de responder), añade un nodo después del Code — por ejemplo un nodo HTTP Request que llame a tu propio endpoint POST /api/results, o un nodo de base de datos (PostgreSQL, Supabase). Después del nodo de persistencia añade un nodo Respond to Webhook explícito para controlar exactamente qué cuerpo devuelves:
{
"status": "ok",
"result": {
"category": "{{ $('Code').item.json.category }}",
"summary": "{{ $('Code').item.json.summary }}",
"confidence": {{ $('Code').item.json.confidence }},
"savedAt": "{{ $now.toISO() }}"
}
}El nodo Respond to Webhook te da control total sobre el status HTTP y el cuerpo de respuesta. Úsalo si quieres devolver 201, incluir cabeceras personalizadas o formar el payload de respuesta de forma diferente al output del último nodo.
Observabilidad y errores
Un pipeline asíncrono que no puedes observar es una caja negra. Estos tres mecanismos cubren el 90 % de los problemas en producción.
Idempotencia: si el route handler de Next.js reintenta la llamada a n8n por un timeout, puedes ejecutar el mismo evento dos veces. Pasa un requestId único en el payload (por ejemplo crypto.randomUUID()) y en el nodo Code de n8n comprueba si ese ID ya existe en tu base de datos antes de llamar a Claude. Si ya existe, devuelve el resultado cacheado directamente.
// En el route handler de Next.js — genera un ID único por petición
const requestId = crypto.randomUUID();
await fetch(n8nWebhookUrl, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ requestId, content, type, language }),
});
Timeouts por capa: cada capa necesita su propio timeout para evitar cascadas de bloqueo. Next.js → n8n: 29 s (justo por debajo del límite de 30 s de n8n en modo síncrono). n8n → Claude: configura "Timeout" en el nodo HTTP Request a 25 s para tener margen. Claude → respuesta: max_tokens acota el tiempo de generación; para tareas cortas 512–1024 tokens son suficientes.
Logs estructurados: n8n almacena el historial de ejecuciones con el input y output de cada nodo — es tu primera línea de depuración. Para logs de aplicación, añade un nodo HTTP Request al final del flujo de error que envíe a tu sistema de logging (Axiom, Grafana Loki, o simplemente un endpoint POST /api/logs en tu app). El payload mínimo útil:
{
"requestId": "{{ $('Webhook').item.json.requestId }}",
"error": "{{ $json.message }}",
"node": "{{ $runIndex }}",
"timestamp": "{{ $now.toISO() }}"
}Alertas por umbral: n8n tiene un nodo Error Trigger que se activa cuando cualquier flujo falla. Conecta ese trigger a un nodo Slack o un email para recibir alertas inmediatas. Combínalo con un contador en Redis o en una tabla de tu base de datos para alertar solo si el número de fallos supera un umbral en los últimos 5 minutos — evitas el ruido de errores puntuales.
La guía completa
Este post cubre el pipeline end-to-end funcional. La guía en PDF profundiza en todo lo que necesitas para llevarlo a producción con confianza:
- Repositorio completo listo para clonar: el route handler de Next.js, el flujo n8n exportado en JSON e importable directamente, las variables de entorno documentadas y el schema de base de datos para persistencia.
- Flujo n8n exportado (JSON): importa el pipeline completo en tu instancia con un clic — Webhook node, HTTP Request (Claude), Code node de validación y Respond to Webhook ya configurados y conectados.
- Validación de webhook y seguridad: firma HMAC del payload, verificación del
x-webhook-secret, rate limiting en el route handler y cómo exponer n8n de forma segura detrás de un proxy inverso. - Estrategia de reintentos completa: backoff exponencial en el nodo HTTP, dead-letter queue para eventos que fallan repetidamente, idempotencia end-to-end y replay manual desde el historial de n8n.
- Deploy en producción: n8n en Railway o Render, variables de entorno, dominios personalizados para el webhook, monitorización con uptime checks y cómo actualizar el flujo sin downtime.