Workflows en YAML: automatizar sin salir del canal

Por: Artiko
buzznostrworkflowsyamlautomatizacioncli

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) cuyo d-tag es el UUID del workflow y cuyo #h es 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 POST HTTP al webhook, o un disparo manual con el evento kind 46020 (KIND_WORKFLOW_TRIGGER).
  • La corrida (run). Una fila en la tabla workflow_runs del 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:

CampoTipoObligatorioDefault
namestring— (no puede quedar vacío ni en blanco)
descriptionstringnonull
triggerobjeto
stepslista— (al menos un paso)
enabledboolnotrue

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 cron se valida con el crate cron, 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 anteponiendo 0 y añadiendo * (0 9 * * 1-5 pasa a ser 0 0 9 * * 1-5 *).
  • El interval usa duraciones tipo 60s, 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:

CampoTipoObligatorioNotas
idstringúnico dentro del workflow
namestringnosolo cosmético
ifexpresiónnosi evalúa falso, el paso se salta, no falla
timeout_secsenteronotope de segundos para ese paso
actionstringetiqueta 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:CamposQué deja en la salida del paso
send_messagetext (req.), channel (opcional, UUID){"sent": true, "event_id": "…"}
send_dmto (req.), text (req.)no implementado en ejecución
set_channel_topictopic (req.)no implementado en ejecución
add_reactionemoji (req.){"added": true, "status": …, "response": …}
call_webhookurl (req.), method, headers, body{"status": …, "body": "…"}
request_approvalfrom (req.), message (req.), timeout (default 24h)suspende la corrida y emite un token
delayduration (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_message y el canal. Si el workflow está ligado a un canal (lo normal), el campo channel solo puede repetir ese mismo canal; cualquier otro UUID falla con SendMessage: channel override must match the workflow channel (<uuid>).
  • call_webhook y 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 rol owner o admin para guardar el workflow y para ejecutarlo.
  • delay es corto de verdad. Tope duro de 270 segundos; pedir más da delay exceeds maximum of 270 seconds. Para esperas largas está schedule.
  • add_reaction necesita un mensaje objetivo. Si el trigger no aporta trigger.message_id (un schedule, 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> en text, author, channel_id, timestamp, emoji, message_id, o cualquier clave del cuerpo de un webhook.
  • {{steps.<ID>.output.<CAMPO>}}, con el segmento del medio literalmente output.

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}}:

FiltroEfecto
truncate(N)corta a N caracteres; si N no es número, error de plantilla
npubcodifica una pubkey hex como su npub bech32 completo; los valores que no son pubkey pasan intactos
truncate_pubkeyalias 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 plantillaEn 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 CapacityExceeded de 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_runs del relay.
  • Campos de nivel superior: name, description, trigger, steps, enabled (por defecto true). Nada más.
  • Cuatro triggers principales — message_posted, reaction_added, schedule, webhook — más diff_posted. schedule exige exactamente uno de cron (UTC, 5/6/7 campos) o interval (mínimo 60 s). El emoji de reaction_added se compara por igualdad exacta contra el content del kind 7: pon el carácter, no un nombre.
  • Siete acciones; send_dm y set_channel_topic parsean pero no se ejecutan. call_webhook exige rol owner o admin y está blindada contra SSRF.
  • Los id de 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_message llega firmado por el relay, atribuido al dueño con un tag p y marcado con buzz:workflow; las menciones @Nombre despiertan 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 runs devuelve [] hoy; el historial se lee en el escritorio.
  • examples/countdown-bot muestra 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