Hilos, reacciones y actividad

Por: Artiko
buzznostrhilosreaccionesnotificacionesnip-10nip-25

Hilos, reacciones y actividad

En el capítulo 4 enviaste mensajes a un canal. Ese es el piso. Este capítulo es sobre lo que pasa encima de un mensaje: las respuestas que forman un hilo, las reacciones que resumen una decisión en un glifo, y el sistema de actividad que te dice qué de todo eso merece tu atención.

Los tres temas están más conectados de lo que parece. En Buzz, si una respuesta aparece en la línea de tiempo del canal o se queda dentro del hilo, si te llega una notificación o no, y si un emoji dispara una automatización, son todas consecuencias de los mismos tags firmados en el evento. Entender esa mecánica evita dos errores caros: ahogar el canal con ruido, y perderte la respuesta que sí te tocaba.

El hilo como unidad de trabajo

Un canal es una fila lineal. Un hilo es una conversación anidada colgando de un mensaje de esa fila. Buzz implementa hilos con NIP-10, el estándar de Nostr para respuestas: no hay una estructura paralela ni un identificador propietario de “hilo”, solo tags e marcados dentro del mismo evento kind:9 que ya conoces.

Una respuesta lleva dos marcadores:

  • ["e", "<id-del-padre>", "", "reply"] — el mensaje inmediato al que respondes.
  • ["e", "<id-de-la-raíz>", "", "root"] — el mensaje que abrió el hilo.

Cuando respondes directamente al primer mensaje del hilo, padre y raíz son el mismo evento. Cuando respondes a una respuesta, la raíz sigue siendo el mensaje original.

La especificación interna del proyecto, docs/nips/NIP-CW.md, define el predicado de forma tajante: un evento es una respuesta si y solo si lleva un tag e marcado con reply. Un evento que solo lleva un tag root, o tags e sin marcador, o ninguno, no es una respuesta. Esa distinción no es cosmética: de ella salen la profundidad, la clasificación de la línea de tiempo y las notificaciones.

flowchart TD
    R["Mensaje raíz — depth 0"] --> A["Respuesta directa — depth 1"]
    R --> B["Otra respuesta directa — depth 1"]
    A --> C["Respuesta a la respuesta — depth 2"]
    C --> D["Más abajo — depth 3"]
    R -.->|"vive en la línea de tiempo"| T["Timeline del canal"]
    A -.->|"solo si lleva broadcast"| T
    B -.-> P["Panel de hilo"]
    C -.-> P
    D -.-> P

La profundidad se calcula en el relay al momento de ingerir el evento: la profundidad de una respuesta es la de su padre más uno. El relay corta en 100: pasado ese punto rechaza el evento con thread depth limit exceeded. No es un límite que vayas a tocar escribiendo a mano, pero sí uno que un agente en bucle puede alcanzar.

Lo que el relay valida al recibir una respuesta

El relay no acepta respuestas huérfanas ni cruzadas. Antes de guardar el evento comprueba la ascendencia y devuelve mensajes de error explícitos:

Mensaje del relayQué significa
reply parent not foundEl evento padre no existe en este relay
parent event belongs to a different channelIntentaste colgar una respuesta de un mensaje de otro canal
parent event has no channel associationEl padre no es un evento de canal
root tag does not match thread ancestryTu tag root no coincide con la raíz real del hilo
thread depth limit exceededSuperaste los 100 niveles de anidamiento

Esto es importante para la práctica diaria: los hilos no cruzan canales. Si quieres continuar una conversación en otro canal, se pega el enlace y se abre un hilo nuevo allá.

Responder desde la terminal

El comando es el mismo buzz messages send del capítulo anterior, más un flag:

buzz messages send \
  --channel 9a2f1c40-6b1d-4e0a-9c77-0f9c2b3d4e55 \
  --content "Reproduje el bug con Node 22. Va el stack en el próximo mensaje." \
  --reply-to 4f0c9d2b1a8e7f6c5d4b3a29180716253442536170819293a4b5c6d7e8f90112

--reply-to recibe el padre inmediato, no la raíz. El CLI hace el resto: consulta ese evento en el relay, lee sus tags NIP-10 y deriva la raíz del hilo por su cuenta. Si el padre no tiene marcador root, entonces él mismo es la raíz. Tú nunca escribes el tag root a mano.

Eso explica un detalle práctico: responder cuesta una consulta extra de red antes del envío, así que un script que responde en ráfaga es más lento que uno que envía mensajes sueltos.

Como cualquier envío, la respuesta acepta las mismas capacidades del capítulo 4:

# Respuesta larga leída de un archivo
buzz messages send --channel <uuid> --reply-to <event-id> --content - < analisis.md

# Respuesta con adjuntos y una mención explícita
buzz messages send --channel <uuid> --reply-to <event-id> \
  --content "@Mara acá está el log completo" \
  --file ./relay.log \
  --mention npub1ejemplo...

El contenido tiene un tope duro de 65 536 bytes. Un log entero no cabe: súbelo como archivo.

Leer un hilo completo

buzz messages thread --channel <uuid> --event <event-id-de-la-raíz>

--event es el identificador del mensaje raíz. El CLI lanza dos filtros en una sola llamada HTTP: uno que trae las respuestas que referencian ese evento por tag e, y otro que trae el evento raíz por id. Después ordena todo por created_at ascendente y lo imprime. Es decir: la salida ya viene en orden de lectura, raíz primero.

Los kinds que el filtro de respuestas incluye son 9, 40002, 40003, 40008 y 45003: el mensaje de stream normal, el mensaje enriquecido v2, la edición, el diff y el comentario de foro. Un hilo con un parche pegado aparece completo; un hilo con reacciones no las incluye, porque las reacciones se consultan aparte.

Dos flags para acotar:

FlagEfecto
--limit <u32>Máximo de respuestas. Default 100, tope 500
--depth-limit <u32>Máxima profundidad de anidamiento incluida

Con --depth-limit 1 obtienes solo las respuestas directas a la raíz, sin las sub-respuestas. Es la forma barata de leer el resumen de un hilo largo antes de decidir si bajas al detalle.

Y como todo lo del CLI, el modo compacto reduce cada evento a lo esencial para escanear rápido. Recuerda que --format es un flag global y va antes del grupo:

buzz --format compact messages thread --channel <uuid> --event <event-id>

Los enlaces profundos a un hilo

Los enlaces buzz://message?channel=<uuid>&id=<hex> que ves pegados en el workspace apuntan a un mensaje concreto: extraes esos dos parámetros y los pasas a messages thread. El parámetro opcional thread que a veces trae la URL se puede ignorar, porque el comando resuelve el hilo completo a partir del id del evento.

El resumen de hilo que ves en el cliente

Cuando el escritorio pinta la línea de tiempo, no cuenta respuestas por su cuenta. El relay le adjunta un evento firmado por él mismo, kind:39005, uno por cada fila que tenga respuestas, con este contenido:

{
  "reply_count": 7,
  "descendant_count": 11,
  "last_reply_at": 1785312400,
  "participants": ["<pubkey-hex>", "..."]
}

participants trae hasta 10 pubkeys, los más recientes primero. Ese evento se sintetiza en el momento de la consulta y nunca se almacena: es metadato de presentación, no historia. Por eso los contadores de un hilo siempre son consistentes con lo que hay, aunque alguien borre una respuesta.

Responder en el canal o responder en el hilo

Esta es la decisión que más impacto tiene en la salud de un canal, y en Buzz está definida por una regla mecánica, no por convención social.

Una fila de la línea de tiempo del canal es top-level si y solo si:

  • su profundidad es 0, es decir no es una respuesta; o
  • su profundidad es 1 y lleva el tag ["broadcast", "1"].

Traducido: por defecto las respuestas no entran nunca a la línea de tiempo del canal. Viven en el panel de hilo. Una respuesta de segundo nivel o más profunda no puede aparecer en el canal bajo ninguna circunstancia, ni con broadcast.

flowchart LR
    M["Envías un mensaje"] --> Q{"¿Lleva tag reply?"}
    Q -->|no| TL["Fila del canal — depth 0"]
    Q -->|sí| D{"¿Cuál es la profundidad?"}
    D -->|"depth 1"| B{"¿Lleva broadcast 1?"}
    D -->|"depth 2 o más"| HI["Solo dentro del hilo"]
    B -->|sí| TL
    B -->|no| HI

El efecto práctico es el que conoces de cualquier chat con hilos bien hechos: el canal se mantiene legible porque solo lleva aperturas de conversación, y el detalle se hunde donde corresponde. La diferencia es que en Buzz esa regla la aplica el relay al servir la ventana del canal, no el cliente al pintar. Cualquier cliente que hable con este relay ve la misma línea de tiempo.

Difundir una respuesta al canal

Cuando una respuesta deja de ser detalle y pasa a ser la conclusión que todos necesitan, la sacas al canal con --broadcast:

buzz messages send \
  --channel <uuid> \
  --reply-to <event-id-de-la-raíz> \
  --content "Resuelto: era el timeout del pool. Fix desplegado en staging." \
  --broadcast

Lo que hace el flag es concreto y verificable: el constructor de eventos añade el tag ["broadcast", "1"] al kind:9. El relay lo lee al ingerir y lo guarda en los metadatos del hilo; a partir de ahí, si la respuesta es de profundidad 1, la ventana del canal la incluye como fila.

Nota sobre la ayuda del comando. El texto que muestra buzz messages send --help para --broadcast dice “Also publish to the Nostr network”. El efecto real en el código —constructor de eventos, ingesta del relay y especificación NIP-CW— es el que se describe aquí: sacar la respuesta a la línea de tiempo del canal. Fíate del comportamiento, no de esa línea de ayuda.

Tres consecuencias que conviene tener claras antes de usarlo:

  1. Solo funciona en profundidad 1. Si respondes a una respuesta, el tag viaja pero no te sube al canal. Si quieres difundir una conclusión que nació abajo en el hilo, respóndele a la raíz.
  2. Fuerza notificación. En el cliente de escritorio, una respuesta con broadcast se considera siempre notificable, sin importar si seguías el hilo o si silenciaste el canal. Es el mecanismo más ruidoso que tienes.
  3. Es una decisión del autor, no del lector. Nadie puede “bajar” tu broadcast del canal ni subir una respuesta ajena. Lo eliges al escribir.

Hoy el flag existe en buzz-cli. El cliente de escritorio lee y clasifica correctamente los broadcast —los pinta como fila del canal y los notifica— pero no encontrarás un interruptor de difusión en su compositor de respuestas.

Reacciones

Una reacción es un evento kind:7, el estándar NIP-25 de Nostr. El contenido del evento es el emoji; el estándar admite además + y - como pulgar arriba y abajo genéricos.

# Reaccionar
buzz reactions add --event <event-id> --emoji "👍"

# Quitar tu reacción
buzz reactions remove --event <event-id> --emoji "👍"

# Ver todas las reacciones de un mensaje
buzz reactions get --event <event-id>

Detalles que importan:

  • No pasas canal. El relay deriva el canal de la reacción desde el tag #e del evento objetivo. Un #h que mande el cliente se ignora para determinar el canal.
  • Quitar una reacción es un borrado. reactions remove busca tu propio kind:7 con ese emoji sobre ese evento y publica un kind:5 de NIP-09 apuntándolo. Si no encuentras la reacción, el error es explícito: no reaction with emoji '👍' found for your pubkey on event <id>.
  • get agrupa por ti. La salida no es la lista cruda de eventos sino un agregado ordenado alfabéticamente por emoji:
{
  "reactions": [
    { "emoji": "🎉", "count": 2, "pubkeys": ["<hex>", "<hex>"] },
    { "emoji": "👍", "count": 5, "pubkeys": ["<hex>", "..."] }
  ]
}

En la aplicación de escritorio, el mismo gesto vive en la barra de acciones que aparece al pasar el cursor sobre un mensaje: un botón React que abre el selector de emoji, junto a Reply, y en el menú de tres puntos las acciones Edit message, Mark read / Mark unread y Follow thread / Unfollow thread. Los cuatro emoji de acceso rápido que trae por defecto son 👍 ❤️ 😂 🎉, y la lista se reordena sola con los que más usas, guardada localmente por comunidad.

Emoji personalizados

Cada miembro publica su propio set de emoji como un evento kind:30030. La “paleta del workspace” no es estado del servidor: es la unión del lado cliente de los sets de todos los miembros, calculada al leer.

buzz emoji list                                  # la paleta completa del workspace
buzz emoji set --shortcode ship-it --url https://…/ship-it.png
buzz emoji rm --shortcode ship-it
buzz emoji export --scope workspace --file paleta.json
buzz emoji import --file paleta.json --dry-run

El shortcode va sin los dos puntos. import fusiona por defecto en tu set; con --replace lo sustituye entero, y --dry-run te muestra qué publicaría sin escribir nada.

Para reaccionar con uno de esos emoji desde el CLI se pasa la URL de la imagen; el contenido del evento pasa entonces a ser :shortcode::

buzz reactions add --event <event-id> --emoji ship-it --emoji-url https://…/ship-it.png

Reacciones que hacen trabajo

Aquí la reacción deja de ser un gesto social. Buzz tiene un tipo de disparador de workflow que se llama reaction_added, y con él un emoji se convierte en un botón.

name: "Aprobar despliegue a staging"
trigger:
  on: reaction_added
  emoji: "🚀"
steps:
  - id: avisar
    action: send_message
    text: "{{trigger.author | npub}} aprobó el despliegue con 🚀"
  - id: marcar
    action: add_reaction
    emoji: "✅"

Cómo funciona, exactamente:

  • El campo emoji es opcional. Si lo omites, el workflow dispara con cualquier reacción en el canal. Si lo pones, la comparación es de igualdad exacta contra el contenido del evento kind:7; cualquier otro emoji se descarta en silencio.
  • Dentro de las condiciones if: tienes las variables trigger_emoji, trigger_message_id y trigger_author. En las plantillas, {{trigger.emoji}}, {{trigger.message_id}} y {{trigger.author}}.
  • La acción add_reaction reacciona sobre el mensaje que originó el disparo, así que exige que trigger.message_id no esté vacío.
sequenceDiagram
    participant U as Tú
    participant R as Relay
    participant W as Motor de workflows
    participant C as Canal
    U->>R: kind 7 con contenido 🚀 sobre el mensaje X
    R->>W: evento de reacción del canal
    W->>W: ¿coincide el emoji declarado?
    W->>C: paso 1 — send_message
    W->>R: paso 2 — add_reaction ✅ sobre X
    C-->>U: el canal muestra el aviso y el visto

El patrón que esto habilita es el que hace que un canal se sienta una herramienta: alguien publica un diff, el equipo lo revisa dentro del hilo, y quien tiene la autoridad le pone 🚀 al mensaje raíz. La reacción es el registro firmado de quién aprobó y cuándo, y además es el gatillo.

Dos advertencias de honestidad técnica:

  • Las compuertas de aprobación de workflows —el paso request_approval que suspende la ejecución y espera un buzz workflows approve --token …— están en la columna 🚧 del README: la infraestructura existe, el pegamento todavía no. Una corrida que llega a esa puerta hoy no se reanuda. Un flujo basado en reaction_added sí funciona completo.
  • buzz workflows runs devuelve [] hoy, porque las corridas se guardan en una tabla del relay y no como eventos Nostr.

Los workflows completos —esquema YAML, condiciones, plantillas, permisos— son el capítulo 11. Aquí solo te interesa saber que la reacción es un disparador de primera clase.

Actividad: qué existe hoy

Buzz tiene un feed de actividad servido por el relay y consumible desde el CLI:

buzz feed get
buzz feed get --types mentions,needs_action --limit 30
buzz feed get --since 1785225600 --types activity

Los tipos válidos son exactamente cuatro, y cualquier otro valor da un error de uso que lista las opciones:

TipoQué trae
mentionsEventos donde tu pubkey aparece en un tag p: mensajes de canal, notas, posts y comentarios de foro, y los eventos git de PR, issue y estado
needs_actionSolo dos kinds, dirigidos a ti: 46010 solicitud de aprobación de workflow, y 40007 recordatorio
activityMensajes, posts de foro y eventos de trabajos de agente en los canales a los que tienes acceso
agent_activityAlias: el relay lo canonicaliza a activity

El límite del CLI es 20 por defecto con tope 50; el relay además corta cualquier consulta de feed en 100 filas. Los eventos vienen ordenados por created_at descendente.

Un detalle que explica por qué el feed se siente limpio: las trazas de ejecución de workflows —los kinds 46001 a 46012— están excluidas a propósito de activity para no inundarlo de ruido. Lo que sí llega es lo que tiene sentido leer como persona.

El feed en la aplicación de escritorio

El escritorio consume las mismas categorías y las agrupa en la vista de inicio. La regla para decidir si un evento te genera una alerta está implementada en desktop/src/features/notifications/lib/shouldNotify.ts y vale la pena conocerla porque determina qué te llega:

flowchart TD
    E["Llega un evento"] --> BR{"¿Es una respuesta con broadcast?"}
    BR -->|sí| N["Notifica"]
    BR -->|no| ME{"¿Te menciona por tag p?"}
    ME -->|sí| N
    ME -->|no| MC{"¿El canal está silenciado?"}
    MC -->|sí| S["No notifica"]
    MC -->|no| TR{"¿Es una respuesta de hilo?"}
    TR -->|no| N
    TR -->|sí| MR{"¿El hilo está silenciado?"}
    MR -->|sí| S
    MR -->|no| PF{"¿Participaste, lo sigues o lo abriste tú?"}
    PF -->|sí| N
    PF -->|no| S

En palabras: broadcast y menciones siempre pasan, por encima incluso del silenciado de canal. Los mensajes de nivel superior de un canal no silenciado notifican. Y una respuesta dentro de un hilo solo te alcanza si tienes relación con ese hilo: escribiste en él, lo abriste tú, o lo marcaste con Follow thread.

Ese “seguir hilo” es una preferencia local de esta máquina, guardada en el localStorage de la aplicación bajo la clave buzz-thread-follows.v1:<tu-pubkey>, con un tope de 500 hilos. No es un evento firmado y no viaja con tu identidad a otro dispositivo. Es la diferencia con tu perfil o tu estado, que sí son eventos firmados: ver el capítulo 3.

El panel de hilo, además, se puede ver de dos formas y el conmutador está sobre el propio hilo: Show thread beside channel lo pone en un panel partido junto al canal, y Expand thread lo abre como superficie de foco. La elección se persiste: el lugar donde formas la opinión es el lugar donde estás mirando el hilo.

Qué no existe todavía

La tabla del README del proyecto separa lo que funciona hoy de lo que está en camino, y esto es lo que corresponde a este capítulo:

EstadoElemento
✅ Funciona hoyCanales, hilos, reacciones, búsqueda, registro de auditoría, aplicación de escritorio, buzz-cli, workflows YAML con disparadores de mensaje, reacción, agenda y webhook
🚧 En construcciónClientes móviles iOS y Android, compuertas de aprobación de workflows, eventos de ciclo de vida de huddles
💭 Opinión firme, sin códigoNotificaciones push, reputación de red de confianza entre relays, funciones de cultura

Léelo literal: no hay notificaciones push. Lo que hay son notificaciones locales de la aplicación de escritorio mientras está abierta, y el feed que puedes consultar cuando quieras desde el cliente o desde el CLI. Si tu flujo depende de que te lleguen alertas al teléfono, ese camino todavía no está construido, y los clientes móviles están en la columna de en medio. El capítulo 13 cubre lo que sí puedes hacer hoy con clientes de terceros.

El documento de visión del feed de actividad de agentes agrega un principio que conviene tener en la cabeza al supervisar delegados: nunca quedarse a oscuras. La ausencia de un evento también es información, así que silencio, inactividad y tiempo agotado se renderizan como estados —“esperando…”, “expiró”— y no como un vacío. Es una declaración de diseño sobre la dirección del producto, no un inventario de funciones ya construidas.

Etiqueta en un espacio compartido con agentes

Todo lo anterior cambia de peso cuando en el canal hay agentes leyendo. No son personas que perdonan una ambigüedad: son procesos suscritos a filtros. Algunas reglas que se caen solas de la mecánica que acabas de ver:

Responde en el hilo por defecto, difunde por excepción. Cada --broadcast es una notificación forzada para todo el canal, humanos incluidos, que pasa por encima del silenciado. Úsalo para conclusiones y bloqueos, no para “gracias”.

Menciona con intención. El tag p es lo que hace que un mensaje aparezca en el feed de mentions de alguien y lo que despierta al agente suscrito a menciones. Mencionar a tres personas “por si acaso” son tres interrupciones. Y ojo: el CLI valida que los pubkeys mencionados sean miembros del canal; si no lo son, falla con la lista de faltantes y el comando exacto para añadirlos.

Una decisión, una reacción. Si el equipo acordó que 🚀 aprueba un despliegue, ese emoji deja de ser decorativo. Ponerlo “de broma” en un mensaje del canal equivocado puede disparar un workflow real. Conviene que el canal declare su convención en el topic o en el canvas, y que los emoji que disparan automatizaciones sean distintos de los de uso social.

Deja el hilo cerrado. Un hilo que termina sin una respuesta que diga el resultado obliga a todo el que llegue después —persona o agente que hace búsqueda— a reconstruirlo. Una respuesta final con broadcast que diga qué se decidió vale más que veinte mensajes de proceso. El capítulo 8 trata de cómo se encuentra eso después.

No edites para cambiar el sentido. Editar es un evento kind:40003 aparte, el original sigue existiendo, y quien ya leyó no vuelve. Si cambió la conclusión, respóndete en el hilo.

Las reacciones ajenas no son tuyas. Solo quien reaccionó puede retirarlas, publicando su propio borrado. Cualquier acción sobre contenido de terceros es materia de moderación y requiere privilegios de operador: eso se cubre en el curso de administrador, no aquí.

Errores frecuentes y qué significan

Lo que vesCausaQué hacer
reply parent not foundEl id que pasaste a --reply-to no existe en este relayVerifica el id con buzz social event --event <id>
parent event belongs to a different channelEstás respondiendo a un mensaje de otro canalLos hilos no cruzan canales: abre uno nuevo o corrige --channel
root tag does not match thread ancestryUn cliente mandó un root inventadoDeja que el CLI derive la raíz: pasa solo --reply-to
thread depth limit exceededMás de 100 niveles de anidamientoResponde a la raíz en lugar de seguir bajando
invalid: reaction target event not foundReaccionaste a un evento que el relay no conoceConfirma que el mensaje existe y que estás en el canal
no reaction with emoji 'X' found for your pubkey…Intentas quitar una reacción que no pusiste túbuzz reactions get te muestra quién puso qué
mentioned pubkeys are not channel membersMencionaste a alguien que no está en el canalEl error trae el comando channels add-member listo
invalid feed type "X"Tipo de feed inexistenteSolo mentions, needs_action, activity, agent_activity

Un mensaje que no verás pero conviene conocer si usas un cliente Nostr de terceros: si te suscribes solo por kind a las reacciones con {"kinds":[7]} no recibirás ninguna. El relay mantiene estrictamente separadas las suscripciones globales de las acotadas a canal, y las reacciones son de canal. Hay que suscribirse con {"kinds":[7],"#h":["<channel-uuid>"]}.

Resumen

  • Los hilos de Buzz son NIP-10 estándar: una respuesta lleva ["e", "<padre>", "", "reply"] y, si corresponde, un marcador root. El CLI deriva la raíz por ti a partir de --reply-to.
  • El relay valida la ascendencia al ingerir: los hilos no cruzan canales, y la profundidad máxima es 100.
  • buzz messages thread --channel <uuid> --event <id> devuelve el hilo ordenado; default 100 respuestas, tope 500, y --depth-limit recorta el anidamiento.
  • Una respuesta nunca entra a la línea de tiempo del canal, salvo que sea de profundidad 1 y lleve el tag ["broadcast", "1"] que añade --broadcast.
  • Un broadcast fuerza notificación por encima del silenciado de canal: es el recurso más ruidoso que tienes, y por eso se reserva para conclusiones.
  • Las reacciones son kind:7 / NIP-25; el canal se deriva del evento objetivo. reactions remove publica un borrado kind:5, y reactions get devuelve el agregado emoji / count / pubkeys.
  • Los emoji personalizados son kind:30030 por miembro; la paleta del workspace es la unión calculada al leer, no estado del servidor.
  • Una reacción puede disparar un workflow con on: reaction_added, con emoji exacto opcional. Las compuertas request_approval, en cambio, todavía no se reanudan.
  • buzz feed get --types … acepta exactamente mentions, needs_action, activity y agent_activity; needs_action son solo aprobaciones de workflow y recordatorios.
  • En el escritorio te notifican: broadcast, menciones, mensajes de nivel superior de canales no silenciados y respuestas de hilos con los que tienes relación. Follow thread es preferencia local, no viaja entre dispositivos.
  • No hay notificaciones push, y los clientes móviles están en construcción según el README del proyecto.

Siguiente: Mensajes directos y privacidad