n8n Workflow Engineer
Cuándo activarlo
- Editar o construir JSON de flujo de trabajo en n8n
- Depurar n8n: "el nodo no se ejecuta", "elemento emparejado no disponible", "los datos estáticos no persisten"
- Operaciones masivas (renombrar, reintentar, eliminar) mediante la API REST de n8n
- Patrones de nodos Telegram, Gmail, HTTP Request, Code, Switch, Merge
- Comportamiento de disparadores programados / zona horaria / cron en n8n
- Comportamiento de la interfaz de n8n v2.x (Publicar/Despublicar, Archivar)
Reglas estrictas
- Auditar antes de editar. Leer el JSON completo del flujo → presentar hallazgos → confirmar → aplicar en una sola pasada. Nunca parchear a ciegas.
- Probar mediante disparador programado. La ejecución manual reinicia
$workflow.staticData. La prueba de deduplicación requiere una ejecución real de cron. - Verificar el resultado real. Revisar el registro de ejecución y el estado final (el mensaje llegó, la fila existe). Que un nodo devuelva
ok: truees una buena señal, no una prueba. El desfase de sincronización de la interfaz engaña a los observadores. - Hacer coincidir el modo del nodo Code con la firma de retorno. Un modo incorrecto + un retorno incorrecto = procesamiento parcial silencioso.
Referencia rápida: los errores más comunes
| Síntoma | Causa raíz | Solución |
|---|---|---|
HTTP Request a localhost:4000 devuelve rechazo/tiempo de espera | n8n en modo puente Docker | Usar el gateway del host: a menudo 172.17.0.1 en el puente predeterminado (verificar con docker network inspect bridge), o mapear host.docker.internal:host-gateway en extra_hosts |
| El mensaje de Telegram nunca llega, sin error | Tiempo de espera por resolución de IPv6 DNS | NODE_OPTIONS=--dns-result-order=ipv4first en docker-compose |
new Date() devuelve UTC a pesar de tener cron en EST | El contenedor carece de la variable de entorno TZ | Establecer tanto TZ como GENERIC_TIMEZONE a tu zona horaria |
parse_mode: Markdown falla silenciosamente o distorsiona | * o _ no emparejados en la salida de IA | Usar parse_mode: HTML + sanitizar antes de enviar |
| 401 en el nodo HTTP Request a LiteLLM | Falta el encabezado de autenticación | Añadir Authorization: Bearer {{ $env.LITELLM_API_KEY }} |
ECONNABORTED: timeout of 15000ms en llamada a Claude | Tiempo de espera predeterminado de 15s | Establecer options.timeout: 90000 |
PUT a /api/v1/workflows/:id devuelve 400 | Campos adicionales en el cuerpo | Limpiar a solo: name, nodes, connections, settings, staticData |
| Renombrar un nodo rompe el flujo silenciosamente | Las referencias de conexiones usan el nombre antiguo | Reemplazar_todo el nombre antiguo tanto en nodes[].name como en las claves de connections{} |
| Elemento emparejado no disponible después de agregación con Code | La transformación N→1→M perdió la línea de elementos | Establecer executeOnce: true, iterar $('UpstreamNode').all() explícitamente |
| La deduplicación con datos estáticos "no funciona" en ejecución manual | La ejecución manual reinicia los datos estáticos | Probar solo con cron programado |
Nodo Code: reglas de modo/retorno
Cada nodo Code tiene un parámetro mode. La discrepancia provoca errores silenciosos.
| Modo | Patrón de acceso | Retorno |
|---|---|---|
runOnceForAllItems (predeterminado) | const items = $input.all(); | return items.map(...) — debe ser un array |
runOnceForEachItem | $json es el elemento actual | return { json: ... } — objeto único, NO un array |
Síntomas de discrepancia:
runOnceForAllItems+ acceder a$json→ solo se procesa el primer elemento silenciosamenterunOnceForEachItem+ devolver un array → error de validación"A 'json' property isn't an object"
Patrón de deduplicación (estado entre ejecuciones)
const store = $getWorkflowStaticData('global');
store.jobIds = store.jobIds || [];
const newItems = items.filter(item => !store.jobIds.includes(item.json.id));
// Persistir nuevos IDs, limitar a los últimos 500 para evitar crecimiento sin control
store.jobIds = [...store.jobIds, ...newItems.map(i => i.json.id)].slice(-500);
return newItems;
Importante: staticData persiste únicamente en ejecuciones programadas. La ejecución manual lo reinicia. Verifica la deduplicación observando dos ejecuciones programadas consecutivas, no haciendo clic en "Ejecutar" dos veces.
Limpieza de caché por ejecución
// Al inicio del primer nodo Code — limpiar cachés transitorias pero preservar el estado entre ejecuciones
const store = $getWorkflowStaticData('global');
for (const k of Object.keys(store)) {
if (k.startsWith('cjob_') || k.startsWith('cprep_') || k === 'pendingJobs') {
delete store[k];
}
}
// NUNCA eliminar jobIds — la deduplicación entre ejecuciones depende de ello
Patrón de observabilidad del pipeline
Establecer contadores en cada nodo Code importante. Leer en el paso de formateo. Añadir al mensaje.
// Etapa 1 — Construir Tickers
const store = $getWorkflowStaticData('global');
store.pipeline = { ticker_count: tickers.length, fixed_count: fixed.length };
// Etapa 2 — Calcular Señales
store.pipeline = store.pipeline || {};
store.pipeline.signal_count = results.length;
// Final — Formatear mensaje
const pl = store.pipeline || {};
const pf = (n) => (n === undefined || n === null) ? '?' : String(n);
const pipelineBlock =
'\n\n📊 Pipeline\n' +
'Build Tickers: ' + pf(pl.ticker_count) + '\n' +
'Señales: ' + pf(pl.signal_count);
Problema de elementos emparejados y solución
Síntoma: Después de un nodo de agregación (N elementos → 1 prompt → 1 respuesta de LLM → dividir en M elementos), el acceso $('UpstreamNode').item.json aguas abajo lanza "Paired item data for item from node 'X' is unavailable".
Soluciones (en orden de preferencia):
-
Establecer
executeOnce: trueen el nodo Code aguas abajo, iterar explícitamente:const upstream = $('UpstreamNode').all(); const outputs = []; for (let i = 0; i < parsed.length; i++) { outputs.push({ json: { ...parsed[i], original: upstream[i].json } }); } return outputs; -
Pasar IDs a través de datos estáticos; buscarlos explícitamente aguas abajo.
-
pairedItem.sourceOverwrite— frágil, último recurso.
Patrones de Telegram
Modo HTML: solo se permiten 7 etiquetas
<b>, <i>, <u>, <s>, <a>, <code>, <pre> — cualquier otra produce 400 Bad Request: Unsupported start tag.
Defensa de tres capas para contenido generado por IA
- Prompt: indicar explícitamente a la IA qué etiquetas están permitidas
- Sanitizar: eliminar etiquetas no soportadas en el código antes de enviar
- Escapar: escapar HTML del contenido de usuario/IA (
&→&,<→<,>→>) antes de incrustar
Suprimir pie de atribución de n8n
"additionalFields": {
"parse_mode": "HTML",
"appendAttribution": false,
"disable_web_page_preview": true
}
Token en .env vs credential
Preferir el patrón de variable de entorno + HTTP Request sobre el nodo integrado de Telegram + credencial:
- Portátil entre migraciones
- Más fácil de rotar (
nano .env+ reinicio del contenedor) - Sin riesgo de bloqueo por clave de cifrado de BD
- Patrón:
POST https://api.telegram.org/bot{{ $env.BOT_TOKEN }}/sendMessage - Compensación: el token reside en la URL de la solicitud, por lo que puede aparecer en datos de ejecución, registros de proxy y mensajes de error. Desactivar el guardado de datos de ejecución para este flujo, o usar la credencial integrada de Telegram si otras personas pueden leer tus registros.
Patrones de OAuth de Gmail
| Problema | Resolución |
|---|---|
invalid_grant ... refresh token revoked | OAuth en modo "Pruebas" expira ~7 días. Re-autenticar mediante la interfaz, o publicar la app en Producción. |
payload.headers vacío (De/Asunto en blanco) | El valor predeterminado simple: true elimina encabezados. Establecer options.simple: false. |
| Faltan alertas de seguridad / recibos | No excluir -category:promotions -category:social -category:updates — Gmail dirige el 90% del correo relevante allí. Usar solo is:unread newer_than:Nd. |
| Necesidad de marcar como leído en varias cuentas | Se necesitan credenciales por cuenta. Una sola credencial de Gmail no puede operar sobre IDs de mensajes de otra cuenta. |
Patrones de API REST (operaciones masivas)
Autenticación: Encabezado X-N8N-API-KEY: <key>.
| Endpoint | Notas |
|---|---|
GET /api/v1/workflows | Devuelve todos (paginados). Filtrar del lado del cliente. |
GET /api/v1/workflows/:id | JSON completo del flujo. |
PUT /api/v1/workflows/:id | Estricto: solo name, nodes, connections, settings, staticData. settings tiene una lista blanca (ver abajo). Eliminar todo lo demás en un ciclo GET→PUT. |
DELETE /api/v1/workflows/:id | Realmente elimina (la interfaz solo ofrece Archivar). Útil para limpieza masiva. |
POST /api/v1/workflows/:id/activate | Similar a Publicar en la interfaz v2.x. |
Lista blanca del campo settings (cuerpo PUT)
executionOrder, timezone, saveExecutionProgress, saveDataErrorExecution, saveDataSuccessExecution, saveManualExecutions, callerPolicy, callerIds, errorWorkflow, executionTimeout
El type del nodo es inmutable después de la creación
Un PUT que cambie node.type devuelve 200 pero el tipo permanece igual. Para convertir (ej. telegram → httpRequest): reemplazar el objeto completo del nodo (nuevo UUID para id, mismo name). Las conexiones se enrutan por nombre, por lo que el reemplazo que preserva el nombre es seguro.
Particularidades de la interfaz n8n v2.x
| Antiguo (v1.x) | Nuevo (v2.x) |
|---|---|
| Interruptor Activar | Publicar (botón verde, arriba a la derecha) / Despublicar |
| Eliminar flujo | Solo Archivar (usar API REST para eliminación real) |
Nodo HTTP Request: imprescindibles para llamadas a LLM
{
"method": "POST",
"url": "http://172.17.0.1:4000/v1/chat/completions",
"sendHeaders": true,
"headerParameters": {
"parameters": [
{"name": "Content-Type", "value": "application/json"},
{"name": "Authorization", "value": "=Bearer {{ $env.LITELLM_API_KEY }}"}
]
},
"sendBody": true,
"contentType": "raw",
"rawContentType": "application/json",
"body": "={{ JSON.stringify({model:'<tu-alias-modelo>',messages:[{role:'user',content:$json.prompt}],temperature:0.3}) }}",
"options": {
"timeout": 90000,
"retry": { "tries": 2, "waitBetweenTries": 5000 }
}
}
El contenedor de n8n no tiene curl
Para pruebas HTTP dentro del contenedor, usar el módulo https integrado de Node mediante docker exec:
docker exec n8n node -e "const https=require('https'); /* solicitud */"
Pre-vuelo SSH (antes de cualquier heredoc en VPS)
ssh -o ServerAliveInterval=30 -o ServerAliveCountMax=10 <usuario>@<host>
# Verificar prompt: [<usuario>@<host> ...]$
# SOLO ENTONCES pegar el heredoc
Pegar un heredoc de bash en PowerShell después de una caída SSH = cientos de errores de análisis sintáctico.
Patrón de suite de evaluación (solo lógica)
Las pruebas con entradas simuladas mediante la función de Evaluaciones de n8n detectan regresiones lógicas sin gastar créditos de API. Volver a ejecutar ANTES Y DESPUÉS de cualquier cambio en la lógica de filtrado, listas de palabras clave o reglas de validación.
Anti-patrones
| Anti-patrón | Qué hacer en su lugar |
|---|---|
| "Ejecútalo otra vez, a ver si funciona" | Leer el registro de ejecución de principio a fin primero |
| Parchear un nodo a la vez en múltiples rondas | Auditar archivo completo → script de parches agrupados → verificar en una sola pasada |
Usar parse_mode: Markdown para salida de IA | parse_mode: HTML + sanitizador |
| Codificar claves de API / IDs de chat en el JSON del flujo | Referenciar {{ $env.KEY }}, almacenar en <archivo-secreto> (chmod 600) |
| Renombrar nodos sin actualizar conexiones | replace_all en el nombre del nodo tanto en nodes[].name como en las claves de connections{} |
| Ejecución manual para probar deduplicación | Esperar al disparador programado |
| Tiempo de espera predeterminado de httpRequest para llamadas a LLM | Establecer mínimo de 90000ms |
Mezclar runOnceForAllItems y $json | Hacer coincidir modo con patrón de acceso |