Workflows en YAML: automatizar sin salir del canal
Workflows en YAML: automatizar sin salir del canal
Hasta aquí el trabajo lo hacían personas y agentes. Este capítulo cubre el tercer tipo de participante: el workflow, un archivo YAML que vive pegado a un canal y ejecuta pasos cuando ocurre algo. No es un agente, no razona, no improvisa.
El motor está en el crate buzz-workflow, que su propia documentación describe
como “channel-scoped automations with sequential execution, variable substitution,
conditional logic, and execution traces”. Cada una de esas palabras es tanto una
capacidad como una limitación.
El modelo mental: definición, disparo, corrida
Tres piezas separadas que conviene no confundir:
- La definición. Un evento Nostr de kind 30620 (
KIND_WORKFLOW_DEF) cuyod-tages el UUID del workflow y cuyo#hes el canal. Es reemplazable, y su contenido es el YAML que el relay valida y almacena como JSON canónico. - El disparo. Un evento del canal (mensaje, reacción, diff), un tick del
scheduler de cron, un
POSTHTTP al webhook, o un disparo manual con el evento kind 46020 (KIND_WORKFLOW_TRIGGER). - La corrida (
run). Una fila en la tablaworkflow_runsdel relay, con su estado, su índice de paso y su traza JSON. Las corridas no son eventos Nostr, y eso tiene consecuencias prácticas que veremos al final.
flowchart TD
A["Escribes el YAML"] --> B["Evento kind 30620 firmado, d-tag = workflow_id, h = canal"]
B --> C{"Que dispara?"}
C -->|"kind 9, kind 7 o kind 40008"| D["on_event del motor"]
C -->|"cron o interval"| E["scheduler, tick cada 60 s"]
C -->|"POST /hooks/id o kind 46020"| F["disparo externo o manual"]
D --> G["Chequeo de autoridad del dueño"]
E --> G
F --> G
G --> H["Se crea el run en workflow_runs"]
H --> I["Pasos en orden: if, plantillas, acción"]
I --> J["Traza visible en el panel de workflows"]
Anatomía del archivo
El esquema completo está en crates/buzz-workflow/src/schema.rs. Los campos de
nivel superior son cinco y solo cinco:
| Campo | Tipo | Obligatorio | Default |
|---|---|---|---|
name | string | sí | — (no puede quedar vacío ni en blanco) |
description | string | no | null |
trigger | objeto | sí | — |
steps | lista | sí | — (al menos un paso) |
enabled | bool | no | true |
Cualquier otro campo de nivel superior no existe. El workflow más pequeño que el
motor acepta es un name, un trigger y un paso — el ejemplo mínimo verificado
del repositorio es name: test-wf con on: webhook y un solo send_message.
Dos detalles de sintaxis que ahorran horas. Primero, trigger y cada paso usan
enums con etiqueta interna: la etiqueta del trigger es la clave on:, la de
la acción es action:, y los campos de cada variante quedan al mismo nivel, no
anidados — se escribe on: schedule con cron: como hermano, nunca
schedule: { cron: ... }. Segundo, un on: o un action: desconocido no se
ignora: es un error de parseo y el workflow no se guarda.
Los triggers
El README lista cuatro tipos de trigger — mensaje, reacción, horario y webhook —
y esa es la lista que cubre el 95% de los casos. El esquema real tiene cinco
variantes: la quinta es diff_posted.
message_posted
Dispara con cada mensaje de canal (kind 9, KIND_STREAM_MESSAGE) publicado en el
canal del workflow. Campo opcional: filter. Sin él, dispara con todos.
trigger:
on: message_posted
filter: "str_contains(trigger_text, 'P1')"
reaction_added
Dispara con reacciones (kind 7, KIND_REACTION). Campo opcional: emoji. Si lo
omites, dispara con cualquier reacción.
Aquí hay una sutileza que cuesta un workflow que nunca dispara: la comparación es
igualdad exacta de cadenas entre lo que escribes en emoji: y el content del
evento kind 7. No hay tabla de nombres cortos: el motor compara
trigger_ctx.emoji != expected y, si difiere, salta el workflow. Como los clientes
publican el carácter del emoji (o :shortcode: para emojis personalizados), lo que
va en el YAML es exactamente eso:
trigger:
on: reaction_added
emoji: "🚀"
Los ejemplos con nombres tipo clipboard que aparecen en los tests de parseo del
repositorio prueban que el YAML se parsea, no que ese nombre case con un 📋 en
tiempo de ejecución.
El contexto se construye distinto aquí: trigger.text y trigger.emoji traen el
contenido de la reacción, y trigger.message_id apunta al mensaje objetivo,
no al evento de la reacción. Eso hace que add_reaction responda sobre el mensaje
correcto.
diff_posted
Igual que message_posted pero escucha los mensajes de diff (kind 40008,
KIND_STREAM_MESSAGE_DIFF), los que produce buzz messages send-diff. Acepta el
mismo filter. Es el gancho natural para automatizar sobre parches; de dónde
salen esos mensajes lo explica
Git dentro de Buzz.
schedule
Dispara por reloj, en UTC. Debe traer exactamente uno de cron o
interval:
trigger:
on: schedule
cron: "0 9 * * 1-5" # o bien: interval: 30m
Reglas exactas que impone la validación:
- Ninguno de los dos →
schedule trigger requires either 'cron' or 'interval'; los dos a la vez →schedule trigger cannot specify both 'cron' and 'interval'; use one or the other. - El
cronse valida con el cratecron, que trabaja con 7 campos (sec min hour dom month dow year). Se aceptan expresiones de 5, 6 o 7 campos: las de 5 se normalizan anteponiendo0y añadiendo*(0 9 * * 1-5pasa a ser0 0 9 * * 1-5 *). - El
intervalusa duraciones tipo60s,30m,1h, con mínimo 60 segundos: el bucle del scheduler tickea cada 60 s, así que lo sub-minuto se rechaza al definir el workflow, no en silencio.
Los disparos perdidos durante una caída del relay no se reponen.
webhook
No lleva campos. Dispara cuando llega un HTTP POST a /hooks/{workflow_id} del
relay.
trigger:
on: webhook
El secreto es obligatorio: cabecera x-webhook-secret (preferida, porque los
proxies no la registran) o parámetro ?secret=. Sin secreto configurado, 401 con
el mensaje literal
webhook secret required but not configured — re-save the workflow to generate one;
sin trigger de webhook, 400 con workflow does not have a webhook trigger.
El cuerpo JSON es opcional y se aplana: cada clave de nivel superior entra
como campo del trigger, accesible luego como {{trigger.<clave>}} en plantillas
y como trigger_<clave> en condiciones.
curl -X POST "$BUZZ_RELAY_URL/hooks/9f2c1b64-0000-4a11-9c3e-77c0a1e2f5aa" \
-H "x-webhook-secret: $WF_SECRET" \
-H "content-type: application/json" \
-d '{"version":"1.4.0","actor":"ci"}'
Los pasos
Cada elemento de steps lleva estos campos comunes, más los de su acción:
| Campo | Tipo | Obligatorio | Notas |
|---|---|---|---|
id | string | sí | único dentro del workflow |
name | string | no | solo cosmético |
if | expresión | no | si evalúa falso, el paso se salta, no falla |
timeout_secs | entero | no | tope de segundos para ese paso |
action | string | sí | etiqueta de la acción, con sus campos al mismo nivel |
La regla del id es la que más sorprende: solo alfanuméricos ASCII y guion
bajo, de 1 a 64 caracteres. Los guiones están prohibidos porque el id se
convierte en el nombre de variable steps_{id}_output_{campo} y el motor de
expresiones leería el - como una resta. Un id: notificar-equipo se rechaza con
must contain only alphanumeric characters and underscores; uno repetido, con
duplicate step id.
Las siete acciones
action: | Campos | Qué deja en la salida del paso |
|---|---|---|
send_message | text (req.), channel (opcional, UUID) | {"sent": true, "event_id": "…"} |
send_dm | to (req.), text (req.) | no implementado en ejecución |
set_channel_topic | topic (req.) | no implementado en ejecución |
add_reaction | emoji (req.) | {"added": true, "status": …, "response": …} |
call_webhook | url (req.), method, headers, body | {"status": …, "body": "…"} |
request_approval | from (req.), message (req.), timeout (default 24h) | suspende la corrida y emite un token |
delay | duration (req.) | {"slept_secs": …} |
Las dos filas marcadas como no implementadas parsean sin problema — puedes
guardar el workflow — pero al ejecutarse devuelven un error NotImplemented y la
corrida se marca fallida. Restricciones reales del resto:
send_messagey el canal. Si el workflow está ligado a un canal (lo normal), el campochannelsolo puede repetir ese mismo canal; cualquier otro UUID falla conSendMessage: channel override must match the workflow channel (<uuid>).call_webhooky la salida al exterior. Es la única acción capaz de sacar contenido del workspace: resuelve el DNS y rechaza direcciones privadas o reservadas, fija la IP en el cliente HTTP, deshabilita proxies y redirecciones, corta a los 10 segundos y descarta respuestas de más de 1 MiB. Además exige rolowneroadminpara guardar el workflow y para ejecutarlo.delayes corto de verdad. Tope duro de 270 segundos; pedir más dadelay exceeds maximum of 270 seconds. Para esperas largas estáschedule.add_reactionnecesita un mensaje objetivo. Si el trigger no aportatrigger.message_id(unschedule, por ejemplo), el paso falla.
Variables, plantillas y filtros
Dentro de los campos de texto puedes interpolar con {{ … }}. La resolución es
de una sola pasada, no recursiva. Solo existen dos formas de ruta válidas:
{{trigger.<campo>}}con<campo>entext,author,channel_id,timestamp,emoji,message_id, o cualquier clave del cuerpo de un webhook.{{steps.<ID>.output.<CAMPO>}}, con el segmento del medio literalmenteoutput.
Una variable desconocida no da error: se emite tal cual, con sus llaves. Si
ves un {{trigger.autor}} literal en el canal, es una falta de ortografía tuya.
Hay tres filtros, con sintaxis {{variable | filtro}}:
| Filtro | Efecto |
|---|---|
truncate(N) | corta a N caracteres; si N no es número, error de plantilla |
npub | codifica una pubkey hex como su npub bech32 completo; los valores que no son pubkey pasan intactos |
truncate_pubkey | alias heredado de npub, no trunca (los prefijos truncados son grindables) |
Cualquier otro filtro da unknown filter: ….
Se resuelven en send_message.text y .channel, send_dm.to y .text,
set_channel_topic.topic, add_reaction.emoji, call_webhook.url, los valores
de headers y body, y request_approval.from y .message. No se resuelven
en call_webhook.method, request_approval.timeout ni delay.duration.
Condiciones
Tanto el filter: de un trigger como el if: de un paso usan el mismo motor. Y
aquí está la trampa que hace tropezar a todo el mundo: en las condiciones las
variables se escriben con guion bajo, no con punto, porque el evaluador no
admite identificadores con punto.
| En plantilla | En if: / filter: |
|---|---|
{{trigger.text}} | trigger_text |
{{trigger.author}} | trigger_author |
{{trigger.channel_id}} | trigger_channel_id |
{{trigger.timestamp}} | trigger_timestamp |
{{trigger.emoji}} | trigger_emoji |
{{trigger.message_id}} | trigger_message_id |
{{steps.deploy.output.status}} | steps_deploy_output_status |
El evaluador no trae funciones de texto de fábrica, así que el motor registra
cuatro explícitamente: str_contains(cadena, aguja),
str_starts_with(cadena, prefijo), str_ends_with(cadena, sufijo) — las tres
devuelven booleano — y str_len(cadena), que devuelve entero. Se combinan con los
operadores habituales: !, &&, ||, ==.
Dos semánticas que conviene tener claras: un if: de paso que evalúa falso
salta el paso, no rompe la corrida; un filter: de trigger que evalúa falso
salta el workflow entero, y si la evaluación falla, también se salta —
fallar en silencio, no disparar por las dudas.
En los triggers por webhook, los campos del cuerpo se registran como variables
trigger_<clave> antes que los campos estándar, de modo que un cuerpo
malicioso con una clave author nunca puede sobrescribir el trigger_author
real. Las claves que empiezan por trigger_ o steps_ se descartan directamente.
Ejemplos completos
1. Triaje de incidentes
Escucha el canal, avisa solo ante un P1 y pide aprobación si además menciona producción.
name: "Incident Triage"
description: "Alert on P1 messages"
enabled: true
trigger:
on: message_posted
filter: "str_contains(trigger_text, 'P1')"
steps:
- id: notify
name: "Send Alert"
timeout_secs: 60
action: send_message
text: "P1 detectado: {{trigger.text | truncate(180)}} — autor {{trigger.author | npub}}"
- id: page
if: "str_contains(trigger_text, 'production')"
action: request_approval
from: "{{trigger.author}}"
message: "Page on-call?"
2. Acuse de recibo por reacción
Alguien marca un mensaje con 📋 y el workflow responde con 👀 sobre ese mensaje. Las
dos cadenas viajan tal cual: la de trigger se compara contra el content del kind 7,
y la de add_reaction se manda verbatim al endpoint
POST /api/messages/{message_id}/reactions del relay.
name: Triage
trigger:
on: reaction_added
emoji: "📋"
steps:
- id: ack
action: add_reaction
emoji: "👀"
3. Recordatorio de standup
Cinco días a la semana, a las 09:00 UTC: el scheduler no conoce tu zona.
name: Daily Standup
description: "Recordatorio de standup, días hábiles"
trigger:
on: schedule
cron: "0 9 * * 1-5"
steps:
- id: prompt
action: send_message
text: "Standup en 5 minutos. Tres líneas: hecho, en curso, bloqueos."
4. Webhook de CI que avisa y luego llama afuera
Este exige rol owner o admin, porque tiene call_webhook. También muestra
cómo un paso lee la salida del anterior.
name: Release Notice
trigger:
on: webhook
steps:
- id: announce
action: send_message
text: "Release {{trigger.version}} desplegada por {{trigger.actor}}."
- id: mirror
if: "str_starts_with(trigger_version, '1.')"
action: call_webhook
url: https://hooks.example.com/notify
method: POST
headers:
content-type: application/json
body: '{"version":"{{trigger.version}}","status":"{{steps.announce.output.sent}}"}'
5. Compuerta de aprobación (lee la advertencia de más abajo)
name: Deploy Approval
trigger:
on: webhook
steps:
- id: request
action: request_approval
from: "@engineering-lead"
message: Approve deploy?
timeout: 4h
- id: notify_approved
if: "steps_request_output_approved == true"
action: send_message
text: Deploy approved
- id: notify_denied
if: "steps_request_output_approved == false"
action: send_message
text: Deploy denied
Registrar un workflow
Desde la terminal, con el grupo workflows de buzz-cli (el capítulo
buzz-cli cubre la autenticación y el
contrato de salida):
# Crear: --yaml recibe el YAML LITERAL, o "-" para leerlo de stdin.
buzz workflows create --channel "$CHANNEL_ID" --yaml - < standup.yaml
buzz workflows list --channel "$CHANNEL_ID"
buzz workflows get --workflow "$WORKFLOW_ID"
# Reemplazar la definición conservando el mismo workflow_id
buzz workflows update --channel "$CHANNEL_ID" --workflow "$WORKFLOW_ID" --yaml - < standup.yaml
# Disparar a mano, con inputs opcionales (debe ser un objeto JSON)
buzz workflows trigger --workflow "$WORKFLOW_ID" --inputs '{"version":"1.4.0"}'
# Borrar (publica un evento de borrado kind 5)
buzz workflows delete --workflow "$WORKFLOW_ID"
Tres detalles que cuestan un intento fallido: --yaml no acepta rutas (o el
YAML entero como argumento, o - con redirección; no existe --yaml-file);
create devuelve el workflow_id, guárdalo; y trigger puede responder 400 con
“workflow not found” si el relay aún no indexó la definición, porque la
indexación es asíncrona.
En el escritorio, la ruta /workflows los lista como tarjetas con insignia de
estado (active, disabled, archived), nombre del canal y resumen del trigger.
El diálogo —Create Workflow / Edit Workflow / Duplicate Workflow—
obliga a elegir canal y trae dos modos: un constructor de formulario, que arma
el YAML por ti a partir del tipo de trigger y de una tarjeta por paso, y un editor
del YAML crudo. Se alterna con el botón Edit as YAML / Back to form; si el
YAML existente no se puede mapear al formulario, el diálogo abre directamente en
modo texto. Al guardar uno con trigger de webhook aparece Webhook Ready con la
URL {relay}/hooks/{workflow_id} y el valor de X-Webhook-Secret: ese secreto
solo se muestra ahí, y si lo pierdes hay que volver a guardar el workflow para
generar otro.
Cómo se ve una corrida
En el canal verás el resultado, no el proceso. Un send_message de workflow
llega como un mensaje de canal normal (kind 9) con tres particularidades: lo firma
la clave del relay, no la tuya; lleva un tag p que atribuye el mensaje al
dueño del workflow; y lleva un tag buzz:workflow que corta la recursión, o sea
que ese mensaje no vuelve a disparar workflows.
Además, si el texto menciona a alguien con @Nombre, el relay resuelve ese nombre
contra los miembros del canal y añade un tag p por cada acierto. Eso permite que
un workflow despierte a un agente, porque el despertar está condicionado por
ese tag (ver
Agentes como compañeros).
En el panel de workflows ves la traza. Al abrir uno aparece Definition con
el JSON canónico y Run History con las corridas: los primeros 8 caracteres del
id, la insignia de estado, la fecha, el número de pasos, la duración y, si falló,
el mensaje de error. Al desplegar una corrida se muestra Execution Trace, un
paso por fila con su estado —completed, failed, error, running, pending,
cancelled, skipped, waiting_approval—, su duración, su bloque Output en
JSON y su Error si lo hubo.
Un detalle que descoloca a quien viene del CLI: buzz workflows runs devuelve
hoy una lista vacía. El comando consulta los eventos 46001-46003 y el relay no
los emite: las corridas viven en la tabla workflow_runs, no como eventos Nostr.
El historial se lee en el escritorio.
Compuertas de aprobación: la infraestructura existe, el pegamento sigue secándose
La tabla del README pone las compuertas de aprobación en la columna 🚧 Being wired up, con la nota literal “infra exists, glue still drying”. Y así es:
Lo que existe. La acción request_approval parsea y valida. Al ejecutarse
genera un token UUIDv4 desde el CSPRNG del sistema y devuelve un resultado de tipo
Suspended. Los kinds están reservados: 46010 solicitud, 46011 concedida, 46012
denegada, y 46030 / 46031 para concesión y denegación. El CLI tiene el comando, y
envía como d-tag el hex(SHA256(token)), no el UUID crudo:
buzz workflows approve --token "$APPROVAL_TOKEN" --approved true --note "revisado"
buzz workflows approve --token "$APPROVAL_TOKEN" --approved false --note "faltan tests"
El escritorio también tiene su pieza: en la traza, un paso en waiting_approval
renderiza una tarjeta Approval Required.
Lo que no existe todavía. El motor no persiste el token ni reanuda la
ejecución. Cuando una corrida llega a la compuerta, el código falla de forma
explícita en lugar de crear filas inalcanzables, y la marca como Failed con el
mensaje approval gates not yet implemented — see WF-08.
La conclusión práctica: el YAML del ejemplo 5 se guarda, y la corrida muere en el
primer paso. No construyas hoy un proceso de despliegue sobre
request_approval. Si necesitas aprobación humana ya, el patrón que funciona es
el social: que el workflow publique el mensaje pidiendo revisión y que la
aprobación sea una reacción 👍 leída por un segundo workflow reaction_added.
Límites que vas a tocar
- 100 corridas concurrentes. Al llegar al tope el motor devuelve
CapacityExceededde inmediato: no encola. - 300 segundos de timeout por paso, ajustable con
timeout_secs. - Caché de 10 segundos en la lista de workflows habilitados por canal. El relay la invalida en su propio proceso al crear, actualizar o borrar un workflow, así que en un despliegue de un solo nodo el cambio se ve al instante; el pestañeo de hasta 10 s —un workflow recién creado que todavía no escucha, o uno borrado que dispara una vez más— aparece en despliegues con varios nodos, porque no hay invalidación cruzada. Es deliberado: el disparo de workflows no es una barrera de control de acceso.
- Hay eventos que nunca disparan workflows. El relay salta el disparo para los
kinds de ejecución de workflow (46001-46012), para los kinds de comando (30620,
46020, 46030, 46031 y los de DM), para los gift wraps de NIP-17, y para cualquier
mensaje firmado por el relay que lleve el tag
buzz:workflow. Entre las cuatro reglas se cierra el bucle de recursión, y de paso los DM privados quedan fuera del alcance de la automatización. - La autoridad se rechequea en cada disparo, no al guardar. Si te sacan del canal, tus workflows dejan de correr; ante un error de lectura, deniega.
Y tres hábitos: empieza por el filter más estrecho posible, porque un
message_posted sin filtro en un canal activo es una corrida por mensaje;
desactiva con enabled: false en vez de borrar, que conserva definición e
historial; y recuerda que la corrida no queda en el canal, así que la
búsqueda solo encuentra el mensaje
resultante.
Cuando el workflow no alcanza: examples/countdown-bot
Un workflow es un archivo declarativo con siete acciones. Cuando necesitas lógica
de verdad, la respuesta en Buzz no es “hazlo más grande”: es escribir un proceso
que hable el protocolo. El repositorio trae un ejemplo mínimo y deliberadamente
aburrido, examples/countdown-bot, que demuestra que los participantes de Buzz
no tienen que ser agentes LLM. Responde a comandos deterministas:
!countdown 5 → 5 4 3 2 1 🚀
!fib 8 → 13 8 5 3 2 1 1 0
@Countdown Bot fib 8 → 13 8 5 3 2 1 1 0
Lo que hace es el mínimo común de cualquier participante: sostener una clave
Nostr, responder al reto NIP-42, publicar un perfil kind 0, suscribirse a eventos
y publicar mensajes kind 9. Al arrancar publica su perfil como Countdown Bot
y, si puede, un auto-alta NIP-29 kind 9000 con role=bot; esa membresía es lo que
lo hace aparecer en miembros y en el autocompletado de menciones.
Tiene dos caminos de identidad. Con BUZZ_BOT_AUTH_MODE=standalone se autentica
con su propia clave y hay que admitirlo explícitamente en relays cerrados. Con
BUZZ_BOT_AUTH_MODE=owner-attested sigue firmando con su clave, pero su evento de
AUTH lleva un tag auth NIP-OA firmado por una clave de dueño ya admitida: el
mismo camino de credencial que reciben los agentes tras el flujo OAuth
dueño/agente. Habilitar ese segundo camino es tarea de operador y se cubre en el
curso de administrador.
Lo que sí te toca a ti: acceso al relay y acceso al canal son cosas separadas.
Aunque el bot logre conectarse, en canales privados un owner o admin tiene que
añadir su pubkey a la membresía del canal antes de que pueda leer y escribir.
Dos decisiones del ejemplo que conviene copiar: los comandos están acotados (máximo 100) para que un solo mensaje no haga spamear al relay, y el bot ignora sus propios mensajes para no entrar en bucle.
Resumen
- Un workflow es un YAML con alcance de canal: definición en kind 30620, disparo,
y corrida almacenada en la tabla
workflow_runsdel relay. - Campos de nivel superior:
name,description,trigger,steps,enabled(por defectotrue). Nada más. - Cuatro triggers principales —
message_posted,reaction_added,schedule,webhook— másdiff_posted.scheduleexige exactamente uno decron(UTC, 5/6/7 campos) ointerval(mínimo 60 s). Elemojidereaction_addedse compara por igualdad exacta contra elcontentdel kind 7: pon el carácter, no un nombre. - Siete acciones;
send_dmyset_channel_topicparsean pero no se ejecutan.call_webhookexige rolowneroadminy está blindada contra SSRF. - Los
idde paso solo admiten alfanuméricos y guion bajo, porque se convierten en nombres de variable. Plantillas con punto, condiciones con guion bajo. - El registro se hace con
buzz workflows create --channel … --yaml -o desde el diálogo del escritorio, que tiene constructor de formulario y editor de YAML crudo; el secreto del webhook se muestra una sola vez. - Un
send_messagellega firmado por el relay, atribuido al dueño con un tagpy marcado conbuzz:workflow; las menciones@Nombredespiertan agentes. - Las compuertas de aprobación están “en construcción”: el token existe, pero el motor no reanuda y la corrida se marca fallida.
buzz workflows runsdevuelve[]hoy; el historial se lee en el escritorio.examples/countdown-botmuestra el escalón siguiente: un proceso propio con su clave, sin LLM, que participa con las mismas reglas.
Siguiente: Git dentro de Buzz: patches, repos y la rama como sala