Package Information
Documentation
n8n-nodes-hefesto
Nodo de comunidad de n8n para Hefesto, el runtime de agentes efímeros de Borah (Cloudflare Worker). Lanza un agente en un sandbox que nace, hace una tarea acotada, persiste su output en R2 y se destruye.
Es el segundo cliente first-party del contrato v0, después del paquete Laravel borah/agent-runtime. Habla exactamente el mismo HTTP: POST /runs, GET /runs/{id}, GET /runs/{id}/outputs/{key}, GET /runs/{id}/logs, POST /runs/{id}/cancel.
Regla que gobierna todo: el Worker es stateless. No conoce agentes, ni pipelines, ni DAGs. La definición del agente viaja resuelta en cada run — por eso los presets viven aquí, en el cliente.
Instalación
Opción A — UI de community nodes (requiere el paquete publicado en npm)
Solo en instancias self-hosted. Mientras el paquete no esté publicado, usa la Opción B.
- Settings → Community nodes → Install a community node.
- Nombre del paquete:
n8n-nodes-hefesto. - Aceptar el riesgo y reiniciar n8n si te lo pide.
Requiere que la instancia tenga permitida la instalación de nodos de comunidad:
N8N_COMMUNITY_PACKAGES_ENABLED=true
# Si además lo quieres disponible como herramienta de un Agent node:
N8N_COMMUNITY_PACKAGES_ALLOW_TOOL_USAGE=true
Opción B — carpeta ~/.n8n/custom (sin publicar en npm)
La vía para instalarlo sin publicar nada: n8n escanea esa carpeta al arrancar y carga cualquier nodo compilado que encuentre. Como el paquete no tiene dependencias de runtime (el dist/ solo importa n8n-workflow, que la propia instancia aporta), basta con plantarle package.json + dist/ dentro — sin node_modules, sin npm en el servidor, sin registry.
B.1 — n8n en Docker (lo normal)
El contenedor ya tiene un volumen montado en /home/node/.n8n. Solo hay que meter el paquete dentro de ese volumen:
# 1. En tu máquina: compilar
cd /ruta/a/hefesto/n8n
npm install && npm run build
# 2. Copiar al servidor, dentro del volumen de n8n
# (ajusta la ruta al volumen real de tu docker-compose)
ssh tu-servidor "mkdir -p /ruta/al/volumen-n8n/custom/n8n-nodes-hefesto"
scp -r package.json dist tu-servidor:/ruta/al/volumen-n8n/custom/n8n-nodes-hefesto/
# 3. Reiniciar el contenedor
ssh tu-servidor "docker restart n8n"
Al recargar la UI aparece el nodo Hefesto en el panel y la credencial Hefesto Runtime API. Si tu volumen no está montado en /home/node/.n8n, define N8N_CUSTOM_EXTENSIONS apuntando a la carpeta custom dentro del contenedor.
B.2 — n8n sin Docker (npm directo en el servidor)
# En tu máquina
cd /ruta/a/hefesto/n8n
npm install && npm run build && npm pack # genera n8n-nodes-hefesto-0.1.0.tgz
scp n8n-nodes-hefesto-0.1.0.tgz tu-servidor:/tmp/
# En el servidor (como el usuario que corre n8n)
mkdir -p ~/.n8n/custom && cd ~/.n8n/custom
npm init -y # solo la primera vez
npm install /tmp/n8n-nodes-hefesto-0.1.0.tgz
# reiniciar n8n (systemctl restart n8n, pm2 restart, lo que uses)
Actualizar
Tras un cambio en el paquete: npm run build en tu máquina, volver a copiar dist/ (B.1) o re-npm pack + npm install del tgz nuevo (B.2), y reiniciar n8n. Para versionado limpio entre varias instancias sin hacerlo público, la alternativa sería un registry privado (GitHub Packages); para 1-2 instancias internas, el scp + restart es lo más simple.
Ojo con el nombre del tipo de nodo. Instalado como paquete de comunidad (opción A) el tipo es
n8n-nodes-hefesto.hefesto. Cargado desde~/.n8n/custom(opción B), n8n lo registra comoCUSTOM.hefesto— un workflow exportado con un método no funciona con el otro sin ajustar el campotypede los nodos. Validado E2E con la opción B: montandopackage.json + dist/en/home/node/.n8n/custom/n8n-nodes-hefesto/e importando/ejecutando por CLI (n8n import:workflow+n8n execute --id).
Credenciales
Crea una credencial Hefesto Runtime API con dos campos:
| Campo | Valor |
|---|---|
| Base URL | La URL raíz de tu Worker agent-runtime, sin barra final (p. ej. https://agent-runtime-worker.YOUR-SUBDOMAIN.workers.dev) |
| Bearer Token | el secreto RUNTIME_BEARER del Worker |
El bearer vive como secret del Worker en Cloudflare (invariante 4 del proyecto) y en el .env gitignored de la raíz del monorepo (RUNTIME_BEARER).
cd worker
wrangler secret list # confirma que RUNTIME_BEARER existe
wrangler secret put RUNTIME_BEARER # rotarlo/ponerlo
Nunca pasa por Laravel ni por n8n más allá de esta credencial. Las claves de modelo (OpenRouter, Tavily, fal, ElevenLabs) no se configuran aquí: viven como secrets del Worker y solo las ve el sandbox.
El test de credencial
El botón Test hace GET /observability/summary. Es el GET autenticado más barato del Worker: devuelve un JSON estático incluso sin binding D1.
⚠️ Es un endpoint first-party: solo responde 200 al RUNTIME_BEARER de Borah. Con un bearer de tenant el router cae a 404.
Un 404 es ambiguo — sale igual con una Base URL equivocada — así que el mensaje del test no afirma que el token sea válido: te dice que compruebes primero la URL y, si es correcta, que probablemente sea un bearer de tenant. Ojo: un bearer de tenant no funciona con los defaults del nodo; requiere la configuración de la sección Tenant first-party vs bearer de tenant.
Presets
Los presets son objetos agent completos y validados en producción. La columna de coste es orientativa: la infra de Cloudflare es ruido (~céntimo/run, ver docs/05-cost-model.md); lo que manda son los tokens del modelo.
| Preset | Instancia | Modelo primary | Skills | Wall máx. | Coste aprox./run |
|---|---|---|---|---|---|
| Quick | lite |
minimax/minimax-m3 |
— | 15 min | ~€0,01–0,05 (infra <$0,001) |
| Researcher | basic |
@cf/moonshotai/kimi-k2.6 |
tavily-cli |
30 min | ~€0,05–0,30 (infra ~$0,0006) |
| Builder | standard-2 |
minimax/minimax-m3 |
— (repo montado) | 6 h | ~€0,30–1,00 (infra ~$0,006) |
| Video | standard-2 + browser |
minimax/minimax-m3 |
dream-sales-video, fal-ai, remotion-best-practices |
6 h | ~€1–3 (browser gratis dentro de 10 h/mes) |
Todos usan vision: gateway/openrouter/google/gemini-3.5-flash y gate igual al primary, entrypoint: opencode run y la egress allowlist del contrato §3.
El preset Custom te deja mandar el objeto agent entero. Claves obligatorias según valida el Worker (worker/src/index.ts → validateRunRequest):
{
"id": "mi-agente", // requerido, string no vacío
"instance": "basic", // requerido: lite|basic|standard-1..4
"needs_browser": false, // requerido, booleano
"template_repo": "org/repo", // requerido (string; "" es válido si no hay skills)
"skills": [], // requerido, array
"model_routing": { "primary": "..." }, // requerido: SOLO primary es obligatorio
"egress_allowlist": [], // requerido, array
"max_wall_seconds": 1800, // opcional, techo 21600
"entrypoint": "opencode run", // opcional
"toolchain": { "php": "8.2" } // opcional
}
La colección Overrides se fusiona encima del preset (instancia, modelo primary, skills, wall máximo, browser, template repo).
Los dos patrones de espera
Un run tarda minutos. El contrato es asíncrono por diseño.
Patrón 1 — Dispatch and Wait (polling, el simple)
El nodo despacha el run y hace GET /runs/{id} cada Poll Interval (Seconds) (default 20, mínimo 5) hasta estado terminal o hasta Wait Timeout (Seconds) (default 3600).
- Devuelve el objeto run completo (
outputs,git,preview,cost,logs_url,session_ref). - Si acaba
failedocancelled, lanza error con elerror.code/error.messagedel run (salvo Continue On Fail). - Si vence el timeout, lanza un error que incluye el run_id y no cancela el run: sigue vivo y puedes recogerlo luego con
Get Run. ActivaCancel Run on Timeoutsi prefieres matarlo. - Si cancelas la ejecución de n8n, el nodo corta la espera en el siguiente ciclo en vez de seguir bloqueando el worker; con
Cancel Run on Timeoutactivo además manda elPOST /cancelbest-effort.
Con varios items la espera es en paralelo. El nodo despacha todos los items primero y luego espera a todos a la vez, así que N items cuestan max(run) y no sum(run): 10 research de 30 min tardan ~30 min, no 5 h. Los cuerpos de todas las peticiones se construyen antes de mandar nada, de modo que un error de validación (preset inexistente, task vacía, override mal escrito) falla sin haber lanzado ni pagado un solo sandbox.
Coste: ocupa un worker de n8n durante toda la espera. Bien para runs de minutos con Quick / Researcher; mal para Builder de 6 h.
Patrón 2 — Dispatch + Wait node + Get Run (callback, el correcto para runs largos)
- Hefesto → Dispatch Run, con Callback URL =
{{ $execution.resumeUrl }}. - Wait node en modo On webhook call. La ejecución se congela: cero consumo mientras el agente trabaja.
- El Worker hace
POSTalresumeUrlcuando el run termina y n8n reanuda. - Hefesto → Get Run con el
run_idpara leer el estado final completo.
El cuerpo del callback ya trae status, outputs, git, cost y logs_url, así que el paso 4 es opcional; hazlo si quieres el shape canónico y no depender del cuerpo entrante.
El callback no va firmado hacia n8n con nada que n8n verifique: el Worker firma con HMAC (
X-Runtime-Signature) y n8n no lo comprueba. Usa unresumeUrl(que ya es un secreto de un solo uso) y no publiques webhooks de resume adivinables.
Ejemplo de workflow mínimo
Patrón 1, un research que escribe a ./output/:
{
"name": "Hefesto research",
"nodes": [
{
"parameters": {},
"id": "a1",
"name": "When clicking Test workflow",
"type": "n8n-nodes-base.manualTrigger",
"typeVersion": 1,
"position": [0, 0]
},
{
"parameters": {
"resource": "run",
"operation": "dispatchAndWait",
"preset": "researcher",
"task": "Investiga la nueva Ley de Prevención de Pérdidas y Desperdicio Alimentario y escribe un resumen ejecutivo en report.md",
"tenant": "n8n-ops",
"pollIntervalSeconds": 20,
"waitTimeoutSeconds": 1800
},
"id": "b2",
"name": "Hefesto",
"type": "n8n-nodes-hefesto.hefesto",
"typeVersion": 1,
"position": [220, 0],
"credentials": {
"hefestoApi": { "id": "1", "name": "Hefesto Runtime API" }
}
},
{
"parameters": {
"resource": "run",
"operation": "downloadOutput",
"runId": "={{ $json.run_id }}",
"outputKey": "={{ $json.outputs[0].key }}",
"binaryPropertyName": "data"
},
"id": "c3",
"name": "Download report",
"type": "n8n-nodes-hefesto.hefesto",
"typeVersion": 1,
"position": [440, 0],
"credentials": {
"hefestoApi": { "id": "1", "name": "Hefesto Runtime API" }
}
}
],
"connections": {
"When clicking Test workflow": {
"main": [[{ "node": "Hefesto", "type": "main", "index": 0 }]]
},
"Hefesto": {
"main": [[{ "node": "Download report", "type": "main", "index": 0 }]]
}
}
}
Operaciones
Dos recursos: Run (agentes efímeros) y File (CRUD sobre el namespace R2 del runtime, /files — contrato §12).
Recurso Run
| Operación | Endpoint | Devuelve |
|---|---|---|
| Dispatch Run | POST /runs |
{ run_id, status, deduplicated, idempotency_key } |
| Dispatch and Wait | POST /runs + polling GET /runs/{id} |
objeto run completo |
| Get Run | GET /runs/{id} |
objeto run completo |
| Cancel Run | POST /runs/{id}/cancel |
{ run_id, status, already_terminal } |
| Download Output | GET /runs/{id}/outputs/{key} |
item con binary data (nombre = último segmento de la key) |
| Get Logs | GET /runs/{id}/logs |
{ run_id, logs, pending, status } |
Recurso File
La key se URL-encodea por segmento (igual que Download Output). El scoping lo aplica el Worker: un bearer first-party puede usar cualquier clave fuera del namespace interno; un bearer de tenant solo bajo t/{tenant}/. Clave o prefijo fuera de namespace o malformado → 400 en cualquier método.
| Operación | Endpoint | Devuelve |
|---|---|---|
| Upload File | PUT /files/{key} |
{ key, size_bytes, content_type } (body = campo binario; su MIME type se persiste como Content-Type) |
| Download File | GET /files/{key} |
item con binary data (nombre = último segmento de la key) |
| List Files | GET /files?prefix=&limit=&cursor= |
un item por objeto ({ key, size_bytes, uploaded_at, cursor, truncated }); página vacía → un item con objects: [] |
| Delete File | DELETE /files/{key} |
{ key, ok: true } (idempotente: 200 exista o no la clave) |
Dos comportamientos que no son un mapeo 1:1 con el HTTP, ambos para que un caso normal no te tumbe el workflow:
- Cancel Run sobre un run ya terminado devuelve
409 already_terminal. El nodo lo trata como éxito idempotente (already_terminal: true): el estado que pedías — que ese run no siga ejecutando — ya se cumple. - Get Logs mientras el run corre devuelve
404, byte-idéntico al404de un run inexistente (los logs se vuelcan a R2 al terminar). El nodo desambigua con unGET /runs/{id}: si el run existe devuelve{ logs: null, pending: true, status }para que ramifiques con un IF; si no existe, lanza el404de verdad.
Advertencias
./output/ — dónde escribe el agente
En un run sin git, el runtime solo persiste lo que haya en /work/output. El nodo lo sabe y añade automáticamente al final de la task:
IMPORTANT: Write every deliverable file into ./output/ — only files under ./output/ are persisted.
En runs con git (mode: create|clone) no lo añade: ahí el entregable es el commit.
409 = idempotencia, no error
POST /runs devuelve 409 cuando la idempotency_key ya tenía un run, junto al run_id existente. El nodo no lo trata como fallo: devuelve ese run_id con deduplicated: true.
La key por defecto es n8n_{workflowId}_{executionId}_{nodo}_{runIndex}_{itemIndex} — determinista, así que un retry de la misma ejecución converge en el mismo run en vez de duplicar el gasto. Los cinco segmentos son necesarios:
- nodo: sin él, dos nodos Hefesto en el mismo workflow colisionan y el segundo recibe un
409con elrun_iddel primero — se quedaría esperando al run equivocado. - runIndex: el índice de ejecución del nodo dentro de la execution. En un bucle (
Split In Batches) el nodo corre una vez por lote y elitemIndexvuelve a 0 en cada uno; sin este segmento todos los lotes dedupearían contra el primero. - itemIndex: un run por item.
Si quieres forzar runs nuevos, pon una Idempotency Key propia con algo variable.
Tenant first-party vs bearer de tenant
Con el RUNTIME_BEARER de Borah (first-party) el campo Tenant que pongas es el que se usa, y las claves R2 son libres.
Con un bearer de tenant el Worker ignora el tenant del body y lo impone desde el bearer. Además, los defaults del nodo no valen: están pensados para first-party. Hay que rellenar los tres campos a mano.
| Requisito | Qué exige el Worker | Qué tienes que poner |
|---|---|---|
callback_url |
https y origen registrado del tenant (callback.ts → allowlist, no un simple chequeo de formato) |
una Callback URL real. El sumidero example.invalid da 400 siempre en modo tenant |
output_prefix |
debe empezar por t/{tenant}/ (index.ts → assertTenantKey) |
Output Prefix empezando por t/<tu-tenant>/ |
inputs / session_ref |
misma regla t/{tenant}/ |
claves R2 con ese prefijo |
Si falta cualquiera de las tres, el run se rechaza con 400 invalid_request antes de arrancar. El nodo es hoy first-party-first: en modo tenant funciona, pero a base de overrides manuales.
callback_url es obligatorio aunque hagas polling
El contrato lo exige (worker/src/index.ts lo lista entre los campos requeridos de validateRunRequest). Si dejas el campo vacío, el nodo manda https://example.invalid/hefesto-callback, un sumidero en un TLD reservado que no resuelve. El callback fallará y el run funcionará igual — pero si vas a usar el patrón 2, pon el resumeUrl de verdad.
⚠️ El sumidero solo vale con bearer first-party. Con un bearer de tenant el Worker valida la URL contra la allowlist de orígenes del tenant y devuelve 400.
Output Prefix
Los outputs se guardan en {output_prefix}{fichero} (concatenación literal). Si lo rellenas sin barra final, el nodo se la añade.
Si dejas el campo vacío, el nodo genera un prefijo por item:
n8n/{workflowId}/{executionId}/{nodo}/{runIndex}-{itemIndex}/
Los índices no son decorativos: cada run necesita su propio prefijo. La sesión de opencode se persiste como {output_prefix}session.json, así que N items compartiendo prefijo se pisarían los outputs y el session.json entre ellos. Si pones un Output Prefix fijo a mano con varios items, ese es exactamente el riesgo que asumes — añádele algo por item ({{ $itemIndex }}).
Presets vs skills
Si pones skills por override pero el agente no tiene template_repo, el nodo falla antes de llamar al Worker: las skills se clonan de ese repo en runtime, no vienen horneadas en la imagen.
Desarrollo
cd n8n
npm install
npm run build # tsc + copia de iconos/JSON a dist/
npm run lint # reglas n8n-nodes-base + community-nodes (cloud compat)
npm run dev # n8n local con el nodo enlazado en caliente
Estructura:
n8n/
├── credentials/HefestoApi.credentials.ts # baseUrl + bearer, test contra /observability/summary
└── nodes/Hefesto/
├── Hefesto.node.ts # 10 operaciones (run + file), propiedades, execute()
├── presets.ts # objetos agent resueltos (quick/researcher/builder/video)
├── transport.ts # una función HTTP + assertOk + encoding de keys R2
├── Hefesto.node.json # metadatos codex
└── hefesto.svg / hefesto.dark.svg
Diseño y cuestiones abiertas: docs/07-n8n-package.md. Contrato: docs/01-runtime-api-contract.md.