Arquitecto de automatización con IA: Día 50
Índice de contenido
Resumen:
Las alertas de los flujos de Trigo Lento llegan al WhatsApp personal de Valeria, mezcladas con los mensajes de las clientas, y los panaderos se enteran tarde de los cambios. En esta entrada, Valeria convierte Slack en el centro de operaciones del equipo: cuatro canales por función, mensajes de aprobación compuestos con Block Kit y enviados por webhooks entrantes, botones que reanudan el flujo de n8n solo si la petición trae una firma válida de Slack, y alertas agrupadas para que el canal no se vuelva ruido. Al terminar, usted sabrá diseñar canales internos con reglas de enrutamiento, componer y comprobar un mensaje de Block Kit, verificar la firma HMAC de una petición, medir el tiempo hasta la aprobación por franja horaria y reducir la fatiga de alertas con números.
¿Cómo citar el presente artículo?
Romero, J. (22 de marzo de 2026). Arquitecto de automatización con IA: Día 50. python-data-engineer. https://www.python-data-engineer.com/post/aai/dia_50/.
Arquitecto de automatización con IA: Día 50 by Jeshua Romero Guadarrama, available under Attribution 4.0 International (CC BY 4.0) at https://www.python-data-engineer.com/post/aai/dia_50/.
Precios, límites y políticas revisados en octubre de 2026: planes, precios y límites de Make, n8n, Zapier, WhatsApp (Meta, Twilio, Wati) y de la API de Claude. Lo específico de Slack se revisó en octubre de 2026: el plan gratuito (90 días de historial y hasta 10 integraciones) y el precio de Pro (8.75 USD por persona al mes en pago mensual y 7.25 en pago anual) salen de la página oficial de precios de Slack; el límite de un mensaje por segundo por canal para los webhooks entrantes, la respuesta 429 con la cabecera Retry-After, el plazo de 3 segundos para confirmar una interacción, la vigencia de 30 minutos y 5 usos de response_url y el algoritmo de firma v0 salen de la documentación oficial para desarrolladores de Slack. Los límites de Block Kit (50 bloques por mensaje, 10 campos por sección, 3 000 caracteres por texto de sección) proceden de fuentes secundarias que coinciden entre sí; no se pudieron leer en la referencia oficial. El JSON del Bloque 10 tiene la estructura verificada contra la documentación, revisado en octubre de 2026, con identificadores de ejemplo; no se exportó desde una instancia de n8n.
Día 50. Slack como centro de operaciones: webhooks, Block Kit y aprobaciones
Introducción
Ayer, en el Día 49 (Accesibilidad y multilingüismo de los mensajes), Valeria midió la legibilidad de las plantillas de WhatsApp, quitó los emojis que un lector de pantalla pronuncia antes del mensaje, separó las plantillas en es_MX y en en_US con una revisión humana para cada traducción, y Trigo Lento ganó un validador de accesibilidad que revisa cada mensaje antes de enviarlo a Meta. Hoy el foco gira hacia dentro: los mensajes que el negocio se manda a sí mismo.
Es domingo 22 de marzo y el obrador está cerrado. Valeria aprovecha la calma para revisar su teléfono, y lo que encuentra explica por qué hoy toca Slack. Su WhatsApp personal mezcla tres cosas que no deberían convivir: las conversaciones con su familia, los avisos de los flujos de n8n (un error del webhook de WhatsApp, una factura que tardó demasiado, un aviso de gasto de Claude) y los pedidos de aprobación del asistente de pedidos del Día 38 (Agentes autónomos con supervisión humana: el asistente de pedidos). El peor día del mes pasado recibió $51$ avisos técnicos, $40$ de ellos por la misma causa en menos de una hora. Mientras tanto, los dos panaderos se enteraron por la libreta, ya con la masa en la mesa, de que una clienta había cambiado su pastel de zanahoria por uno de chocolate.
El Día 38 dejó además una deuda escrita. El asistente de pedidos pide aprobación a Valeria por WhatsApp y espera hasta dos horas en un nodo Wait de n8n; con su forma real de trabajar, Valeria resuelve el $85.4%$ de esas aprobaciones en $30$ minutos, por debajo de la meta del $90%$. Aquel día se calculó que un relevo de Don Ernesto a los $20$ minutos subiría la cifra al $92.5%$, y se anotó que el relevo llegaría “con Slack en el Día 50”. Hoy es ese día.
Don Ernesto, que pasó a dejar pan para la semana, ve la palabra Slack en la pantalla del cuaderno y corta la explicación antes de que empiece: “Yo no voy a instalar otra aplicación, Valeria. Ya tengo el WhatsApp, la báscula y el termómetro del horno. Si eso de Slack me va a pitar en la bolsa mientras amaso, no cuente conmigo.” Tiene razón en lo esencial: un centro de operaciones que obliga a todos a cambiar de costumbres fracasa el primer lunes. La respuesta, que se verá en la sección dedicada a él, es que Don Ernesto no necesita Slack para estar en el centro de operaciones.
En la presente entrada, descubrirá cómo convertir Slack en la sala de máquinas de un negocio pequeño. Diseñará cuatro canales por función con sus reglas de enrutamiento, revisará los límites del plan gratuito y lo que significan para la auditoría, enviará mensajes con webhooks entrantes y los compondrá con Block Kit, el formato de bloques de Slack. Construirá un mensaje de aprobación con botones, lo dibujará a partir de su propio JSON y comprobará que cumple los límites. Después verificará la firma de cada pulsación con HMAC-SHA256, simulará el tiempo hasta la aprobación por franja horaria con y sin el relevo, agrupará las alertas para que el canal no se vuelva ruido y dejará listo el workflow TL Slack operaciones v1, que reanuda el flujo del Día 38 solo cuando la petición es auténtica y nadie ha decidido antes.
Objetivos de aprendizaje
Al terminar esta entrada, usted podrá:
- Diseñar los canales de Slack de Trigo Lento por función (pedidos, aprobaciones, alertas, mayoristas) y decidir qué llega a cada uno.
- Enviar mensajes con webhooks entrantes y componerlos con Block Kit (secciones, campos, contexto y botones).
- Recibir las pulsaciones de los botones de aprobación, verificar la firma de Slack y reanudar el flujo en n8n.
- Evitar la fatiga de alertas con agrupación, severidades y resúmenes, respetando los límites de tasa.
Por qué un centro de operaciones
Un centro de operaciones es el lugar único donde el equipo ve lo que el sistema está haciendo y lo que necesita de una persona. En una fábrica es la sala de control; en Trigo Lento será un espacio de trabajo de Slack con cuatro canales. La razón de fondo no es tecnológica, es de separación: el equipo no debe vivir en el WhatsApp de los clientes. Cuando los avisos internos viajan por el mismo canal que las conversaciones con las clientas, pasan tres cosas, y Valeria ya las ha vivido todas:
- Lo urgente se pierde entre lo social. Un aviso de “el tope de gasto de Claude está al $90%$” queda entre un audio de la tía y la foto de un pastel.
- Los avisos dependen de una sola persona. Si llegan al teléfono de Valeria, nadie más se entera; si Valeria está de vacaciones, el sistema habla solo.
- No hay reglas sobre qué se publica. En un grupo de WhatsApp improvisado acaban pegados mensajes enteros de clientas con su teléfono y su dirección, que es justo lo que el Día 6 prohibió.
El primer bloque fija las importaciones, la semilla y los dos supuestos que recorren el día: las $222$ aprobaciones al mes que pedirán los flujos de Trigo Lento (los borradores de correo para mayoristas del Día 47, los pasteles personalizados del Día 38, las publicaciones que llegarán con el módulo 7 y las respuestas a quejas) y el horario laboral del equipo:
# Importaciones base, semilla de la serie, carpeta del día y supuestos fijos en Python:
import warnings
warnings.filterwarnings('ignore')
import hashlib
import hmac
import json
import math
import sqlite3
import textwrap
import time
from datetime import datetime, timedelta
from pathlib import Path
from urllib.parse import urlencode, parse_qs
from zoneinfo import ZoneInfo
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
from matplotlib.patches import FancyBboxPatch, FancyArrowPatch, Polygon
# Generador aleatorio con la semilla global de la serie:
rng = np.random.default_rng(2026)
pd.set_option('display.width', 120)
carpeta = Path('trigo_lento') / 'dia_50'
carpeta.mkdir(parents = True, exist_ok = True)
CDMX = ZoneInfo('America/Mexico_City')
# Aprobaciones al mes por tipo (ficha del día) y horario laboral del equipo:
APROBACIONES = {'correo_mayorista': 120, 'pastel': 40, 'publicacion': 36, 'queja': 26}
HORARIO = (7, 19) # de 7:00 a 19:00, de lunes a sábado
print('numpy', np.__version__, '| pandas', pd.__version__)
print(f'Aprobaciones al mes: {sum(APROBACIONES.values())} | a 1 minuto cada una: '
f'{sum(APROBACIONES.values()) / 60:.1f} h de Valeria')
numpy 2.4.6 | pandas 3.0.3
Aprobaciones al mes: 222 | a 1 minuto cada una: 3.7 h de Valeria
A un minuto por aprobación, las $222$ aprobaciones del mes son $3.7$ horas de Valeria. No es mucho tiempo, pero está repartido en $222$ interrupciones, y cada una llega cuando ella está haciendo otra cosa. El trabajo de hoy es que esas interrupciones lleguen al lugar correcto, con todo lo necesario para decidir en un minuto y sin que se mezclen con el resto.
La decisión de diseño más importante es organizar los canales por función, no por herramienta ni por persona. Un canal #n8n mezclaría pedidos, errores y aprobaciones porque todos salen de n8n; un canal por persona obligaría a cada una a revisar los demás. En cambio, un canal por función responde a una sola pregunta: ¿qué tengo que hacer con lo que veo aquí? En #pedidos-del-dia se lee; en #aprobaciones se decide; en #alertas se diagnostica; en #mayoristas se atiende a Brújula. La Figura 1 lo muestra como una sala con cuatro pantallas.
El siguiente bloque escribe los canales como DataFrame con su volumen esperado y una función enrutar que decide a qué canal va cada evento. La función tiene dos reglas que no dependen del tipo de evento: fuera del horario laboral, solo lo crítico se publica de inmediato y lo demás espera al resumen de las 7:00; y antes de publicar, el evento se queda únicamente con los campos de una lista blanca, de modo que un teléfono o el texto completo de una clienta no pueden llegar a Slack aunque un flujo los envíe por error:
# Canales de Slack por función y reglas de enrutamiento de eventos en Python:
canales = pd.DataFrame([
('#pedidos-del-dia', 'Encargo nuevo y lista de las 7:00', 'Mostrador y panaderos', 'informativa', 18 * 26 + 26),
('#aprobaciones', 'Borradores que esperan un sí', 'Valeria (y el obrador a los 20 min)', 'acción', 222),
('#alertas', 'Fallos de flujos, cuotas y gasto', 'Valeria', 'alta o crítica', 26 * 4),
('#mayoristas', 'Brújula: confirmaciones, cambios, facturas', 'Valeria y mostrador', 'informativa', 26 * 2 + 4),
], columns = ['canal', 'que_se_publica', 'quien_lo_lee', 'severidad', 'mensajes_mes'])
print(canales.to_string(index = False))
print('Mensajes internos al mes:', canales.mensajes_mes.sum())
RUTAS = {'pedido_nuevo': '#pedidos-del-dia', 'lista_del_dia': '#pedidos-del-dia',
'aprobacion': '#aprobaciones', 'error_flujo': '#alertas', 'presupuesto': '#alertas',
'brujula': '#mayoristas'}
CAMPOS_PERMITIDOS = {'tipo', 'severidad', 'cliente', 'producto', 'cantidad', 'entrega', 'importe', 'causa'}
def enrutar(evento):
# Canal según el tipo; fuera de horario, lo no crítico espera al resumen de las 7:00:
hora = evento['hora']
canal = RUTAS.get(evento['tipo'], '#alertas')
if not (HORARIO[0] <= hora.hour < HORARIO[1]) and evento.get('severidad') != 'critica':
canal = 'resumen de las 7:00'
limpio = {k: v for k, v in evento.items() if k in CAMPOS_PERMITIDOS} # nada de teléfonos ni textos
return canal, limpio
dia = datetime(2026, 3, 23, tzinfo = CDMX)
eventos = [
{'tipo': 'pedido_nuevo', 'hora': dia.replace(hour = 9, minute = 12), 'cliente': 'cli_7f3a9c21e0',
'producto': 'P01', 'cantidad': 2, 'telefono': '+52 55 0000 0017', 'texto': 'Hola, quiero dos hogazas...'},
{'tipo': 'aprobacion', 'hora': dia.replace(hour = 10, minute = 40), 'producto': 'P09', 'importe': 460},
{'tipo': 'error_flujo', 'hora': dia.replace(hour = 23, minute = 5), 'severidad': 'alta', 'causa': 'webhook_5xx'},
{'tipo': 'presupuesto', 'hora': dia.replace(hour = 23, minute = 50), 'severidad': 'critica', 'causa': 'tope_claude'},
{'tipo': 'brujula', 'hora': dia.replace(hour = 7, minute = 40), 'producto': 'P02', 'cantidad': 40},
]
for evento in eventos:
canal, limpio = enrutar(evento)
print(f"{evento['hora']:%H:%M} {evento['tipo']:<13} -> {canal:<20} campos: {sorted(limpio)}")
canal que_se_publica quien_lo_lee severidad mensajes_mes
#pedidos-del-dia Encargo nuevo y lista de las 7:00 Mostrador y panaderos informativa 494
#aprobaciones Borradores que esperan un sí Valeria (y el obrador a los 20 min) acción 222
#alertas Fallos de flujos, cuotas y gasto Valeria alta o crítica 104
#mayoristas Brújula: confirmaciones, cambios, facturas Valeria y mostrador informativa 56
Mensajes internos al mes: 876
09:12 pedido_nuevo -> #pedidos-del-dia campos: ['cantidad', 'cliente', 'producto', 'tipo']
10:40 aprobacion -> #aprobaciones campos: ['importe', 'producto', 'tipo']
23:05 error_flujo -> resumen de las 7:00 campos: ['causa', 'severidad', 'tipo']
23:50 presupuesto -> #alertas campos: ['causa', 'severidad', 'tipo']
07:40 brujula -> #mayoristas campos: ['cantidad', 'producto', 'tipo']
Los cuatro canales suman $876$ mensajes internos al mes, muy cerca de los $900$ que estimó el Día 46 al dibujar el mapa de canales. La Tabla 1 los resume.
Tabla 1. Canales de Slack de Trigo Lento: qué se publica, quién lo lee, severidad y frecuencia esperada.
| Canal | Qué se publica | Quién lo lee | Severidad | Frecuencia esperada |
|---|---|---|---|---|
#pedidos-del-dia | Cada encargo nuevo (producto, cantidad, hora, cliente seudonimizado) y la lista de las 7:00 | Mostrador y panaderos | Informativa | 494 al mes, unos 19 por día laboral |
#aprobaciones | Borradores que esperan un sí: correos, pasteles, publicaciones y respuestas a quejas | Valeria; el obrador a los 20 minutos para los pasteles | Acción | 222 al mes, unas 9 por día |
#alertas | Fallos de flujos, cuotas agotadas y avisos de gasto, ya agrupados | Valeria | Alta o crítica | 104 al mes como objetivo, unas 4 por día |
#mayoristas | Brújula: confirmación diaria, cambios de cantidad y facturas semanales | Valeria y mostrador | Informativa | 56 al mes |
La traza de los cinco eventos de ejemplo enseña las dos reglas generales en acción:
- El encargo de las 9:12 llega a
#pedidos-del-diasin teléfono ni texto. El evento traíatelefonoytextoporque así lo entrega el webhook de WhatsApp; la lista blanca deja solocantidad,cliente,productoytipo. El cliente aparece comocli_7f3a9c21e0, el seudónimo del Día 6, que basta para que el mostrador lo encuentre en la base. - El error de las 23:05 no despierta a nadie. Es de severidad alta, pero llega fuera de horario, así que espera al resumen de las 7:00. En cambio, el aviso de las 23:50 sobre el tope de gasto de Claude es crítico y se publica en
#alertasa esa hora, porque esperar a la mañana podría significar flujos detenidos. - El pedido diario de Brújula va a su propio canal. Marisol es una clienta con un contrato y un nivel de servicio propio; mezclar sus avisos con los encargos sueltos haría que un cambio de $40$ baguettes se leyera como un pedido más.
Figura 1. La sala de máquinas de Trigo Lento: cuatro pantallas, una por canal (pedidos del día, aprobaciones, alertas y mayoristas), con quién mira cada una, cuántos mensajes recibe al mes y la pantalla del obrador para Don Ernesto, que no usa Slack.
El plan gratuito y sus límites
Antes de crear un solo canal conviene saber qué cuesta y qué se pierde. Según la página oficial de precios de Slack, revisada en octubre de 2026, el plan gratuito conserva $90$ días de historial de mensajes y archivos y admite hasta $10$ integraciones (aplicaciones conectadas al espacio de trabajo). El plan Pro cuesta $8.75$ USD por persona al mes en pago mensual y $7.25$ en pago anual, y elimina los dos límites.
Para Trigo Lento, las cuentas son rápidas. En Slack estarían cinco personas: Valeria, las dos de mostrador y los dos panaderos. Con Pro en pago mensual serían $5 \cdot 8.75 = 43.75$ USD al mes, casi el presupuesto entero de $45$ USD de todo el ecosistema; en pago anual, $5 \cdot 7.25 = 36.25$ USD, que tampoco caben junto a la mensajería y la API de Claude. El plan gratuito no es una opción de ahorro, es la única opción que cabe. Y cabe bien: Trigo Lento usa una sola integración, la aplicación TL Operaciones que el workflow de n8n usa para publicar y recibir los botones, de modo que le sobran nueve de las diez.
El límite que sí obliga a diseñar es el historial. Un mensaje de #aprobaciones de hace cuatro meses ya no se puede leer en el plan gratuito, y eso tiene una consecuencia directa para la auditoría: el registro de aprobaciones no puede vivir en Slack. Si una clienta pregunta en julio por qué se le aceptó un pastel en marzo, la respuesta tiene que estar en trigo_lento.sqlite (y en la base de Postgres de n8n), con la persona, la hora y la decisión. Slack es la pantalla; la base de datos es el libro de actas. El Bloque 9 escribe ese registro.
La Tabla 2 reúne los límites de Slack que usa la entrada, con su fuente.
Tabla 2. Límites y precios de Slack que condicionan el diseño de Trigo Lento (revisado en octubre de 2026).
| Concepto | Valor | Fuente | Consecuencia en Trigo Lento |
|---|---|---|---|
| Historial del plan gratuito | 90 días | Página de precios de Slack | El registro de aprobaciones vive en SQLite y Postgres |
| Integraciones del plan gratuito | Hasta 10 | Página de precios de Slack | Una sola aplicación, TL Operaciones |
| Plan Pro | 8.75 USD por persona al mes (7.25 en pago anual) | Página de precios de Slack | 43.75 USD para cinco personas: no cabe en el presupuesto |
| Ritmo de publicación | 1 mensaje por segundo por canal, con ráfagas cortas | Documentación de límites de tasa | Pausa de 1 segundo entre mensajes y agrupación de alertas |
| Exceso de ritmo | HTTP 429 con Retry-After en segundos | Documentación de límites de tasa | Esperar lo que diga la cabecera, nunca un tiempo fijo |
| Bloques por mensaje | 50 (100 en ventanas modales) | Fuentes secundarias | El mensaje de aprobación usa 5 |
| Confirmar una interacción | HTTP 200 en 3 segundos | Documentación de interactividad | n8n responde antes de tocar la base de datos |
response_url | Hasta 5 usos en 30 minutos | Documentación de interactividad | Se usa una vez para cambiar el mensaje tras decidir |
| Tolerancia de la firma | 5 minutos | Documentación de verificación de peticiones | Se rechaza toda petición con marca de tiempo más vieja |
Webhooks entrantes
La forma más sencilla de publicar en un canal de Slack es un webhook entrante (incoming webhook): una dirección única que Slack asigna a un canal cuando se instala la aplicación y que acepta mensajes por POST con un cuerpo JSON. No hay que gestionar tokens ni llamar a la API web: el flujo envía el JSON a la dirección y el mensaje aparece en el canal con el nombre y el icono de la aplicación. Trigo Lento tendrá cuatro, uno por canal.
Las reglas del webhook entrante, según la documentación oficial revisada en octubre de 2026, son pocas y conviene tenerlas a mano:
- La dirección es una credencial. Quien la tenga puede publicar en el canal. Slack busca activamente las direcciones filtradas en repositorios públicos y las revoca; por eso en Trigo Lento se guardan como variables de entorno del servidor de n8n y como credenciales, nunca en un workflow exportado ni en este cuaderno.
- El cuerpo lleva siempre
text. Aunque el mensaje se componga con bloques, el campotextes obligatorio (sin él, Slack respondeno_text) y es lo que muestra la notificación del teléfono y lo que leen los lectores de pantalla. Es la misma idea de texto alternativo que el Día 49 aplicó a las imágenes. - La respuesta buena es
200con el textook. Los errores llegan con códigos 400, 403 o 404 y una cadena que explica el motivo:invalid_payload(JSON mal formado),action_prohibited(un administrador restringió la publicación),no_serviceono_active_hooks(el webhook ya no existe),channel_is_archived. Ninguno de ellos mejora reintentando. - El ritmo es de un mensaje por segundo por canal. Slack tolera ráfagas cortas, pero si se sostiene el exceso responde 429 con la cabecera
Retry-After, que dice cuántos segundos esperar. La documentación advierte además que los mensajes enviados en ráfaga pueden no guardarse. - Lo publicado no se puede borrar ni editar desde el webhook, y la respuesta no devuelve el identificador del mensaje (
ts), así que tampoco se puede responder en su hilo sin consultar después el historial. Para eso está el métodochat.postMessagede la API web, que usa el token de la aplicación.
Esta última regla explica una decisión que se verá en las alertas: los mensajes que necesitan hilo o edición (las alertas agrupadas) salen por el nodo Slack de n8n, que usa chat.postMessage; los que se publican y se olvidan (encargos nuevos, la lista del día, los avisos de Brújula) salen por webhook entrante. Las aprobaciones salen por webhook y se actualizan con response_url, como se verá enseguida.
El bloque siguiente envía el mensaje de aprobación del Bloque 3 a #aprobaciones. La función publicar distingue tres casos: ok, que termina; 429, que espera lo que pide Retry-After y reintenta; y cualquier otro error, que no se reintenta porque significa que el mensaje o el webhook están mal. Entre mensaje y mensaje hay una pausa de un segundo, el ritmo que pide Slack. Este bloque llama a un servicio real y no se ejecutó para escribir la entrada; su salida es un ejemplo comentado:
# Envío del mensaje de aprobación al webhook entrante de #aprobaciones con manejo de 429 en Python:
import json
import os
import time
from pathlib import Path
import requests
# La URL del webhook es un secreto: se lee del entorno, nunca se escribe en el código ni en el flujo.
# Tiene la forma https://hooks.slack.com/services/T.../B.../EJEMPLO-NO-REAL (cada canal tiene la suya):
URL_APROBACIONES = os.environ['TL_SLACK_WEBHOOK_APROBACIONES']
mensaje = json.loads((Path('trigo_lento') / 'dia_50' / 'aprobacion_pastel.json').read_text(encoding = 'utf-8'))
def publicar(url, cuerpo, max_intentos = 4):
# Publica un mensaje y respeta Retry-After cuando Slack responde 429:
for intento in range(1, max_intentos + 1):
respuesta = requests.post(url, json = cuerpo, timeout = 10)
if respuesta.status_code == 200 and respuesta.text == 'ok':
return intento, 'ok'
if respuesta.status_code == 429:
espera = int(respuesta.headers.get('Retry-After', '1'))
print(f'Intento {intento}: 429, Slack pide esperar {espera} s')
time.sleep(espera)
continue
# 400 invalid_payload, 403 action_prohibited, 404 no_service: no se reintenta, se corrige:
return intento, f'{respuesta.status_code} {respuesta.text}'
return max_intentos, 'agotado'
# Tres aprobaciones seguidas, con una pausa de 1 s entre mensajes (1 por segundo por canal):
for numero in ['apr_0323_014', 'apr_0323_015', 'apr_0323_016']:
cuerpo = json.loads(json.dumps(mensaje).replace('apr_0323_014', numero))
intentos, resultado = publicar(URL_APROBACIONES, cuerpo)
print(f'{numero}: {resultado} en {intentos} intento(s)')
time.sleep(1.0)
# Salida de ejemplo (puede variar):
# apr_0323_014: ok en 1 intento(s)
# Intento 1: 429, Slack pide esperar 1 s
# apr_0323_015: ok en 2 intento(s)
# apr_0323_016: ok en 1 intento(s)
En la salida de ejemplo, el segundo mensaje recibe un 429 y Slack pide esperar un segundo; al reintentar, se publica. Nunca se reintenta con un tiempo fijo inventado: el servidor sabe cuánto falta para que su cubeta de peticiones se vacíe, y la cabecera lo dice. Compárelo con la espera exponencial que usó la serie para la API de Claude: allí el SDK no siempre recibe una indicación del servidor y por eso duplica la espera; aquí hay una cifra explícita, y adivinar sería peor.
Atención aquí: la dirección del webhook entrante y el secreto de firma de la aplicación son dos llaves distintas, y las dos se pierden con la misma facilidad: un workflow exportado para pedir ayuda en un foro, una captura de pantalla de la configuración, un cuaderno subido a un repositorio. Con la dirección, cualquiera puede publicar en #aprobaciones un mensaje falso con botones; con el secreto de firma, cualquiera puede fabricar pulsaciones que el workflow aceptará como auténticas y aprobar pedidos. Guarde ambas en variables de entorno del servidor de n8n (TL_SLACK_WEBHOOK_APROBACIONES, TL_SLACK_SIGNING_SECRET), revise cada exportación con el detector de secretos del Día 17 antes de compartirla y, si una se filtra, regenérela en la configuración de la aplicación el mismo día: regenerar es gratis, una aprobación falsa no.
Block Kit
Un mensaje de solo texto sirve para avisar, pero no para decidir. Block Kit es el formato de Slack para componer mensajes con bloques: piezas apiladas, cada una con un tipo y una función. Trigo Lento usa cuatro tipos:
section: un bloque de texto con formatomrkdwn(la variante de Markdown de Slack, con*negritas*y saltos de línea) o una cuadrícula de hasta $10$ campos cortos en dos columnas, ideal para “Producto”, “Entrega”, “Importe”.context: una línea pequeña y gris para lo secundario: el identificador del borrador, cuándo caduca, quién lo generó.divider: una línea horizontal que separa la información de la acción.actions: una fila de elementos interactivos, aquí tres botones. Cada botón tiene un texto visible, unaction_idque identifica la acción, unvalueque viaja con la pulsación y, opcionalmente, un estilo (primaryen verde,dangeren rojo) y un diálogo de confirmación (confirm).
Las fuentes secundarias consultadas coinciden en los límites: $50$ bloques por mensaje ($100$ en ventanas modales y en la pestaña de inicio), $10$ campos por sección, $3,000$ caracteres por texto de sección y $25$ elementos por bloque de acciones. Un mensaje de aprobación bien diseñado está muy lejos de todos ellos; si un día se acerca, el problema no es el límite, es el mensaje.
El mensaje de aprobación de un pastel personalizado queda así. El campo text es la versión de una línea para la notificación; los bloques son la versión completa. Observe tres decisiones: el cliente aparece con su seudónimo, no con su nombre; el importe dice “catálogo” porque lo calculó el código del Día 38 y no el agente; y el botón Rechazar pide confirmación, porque es el único que provoca un mensaje a la clienta:
{
"text": "Aprobación pendiente: pastel P09 para el sábado 28 de marzo a las 17:00, 460 MXN (apr_0323_014)",
"blocks": [
{
"type": "section",
"text": {
"type": "mrkdwn",
"text": "*Pastel personalizado pendiente de aprobación*\nEl agente de pedidos dejó un borrador que ocupa el horno."
}
},
{
"type": "section",
"fields": [
{"type": "mrkdwn", "text": "*Producto*\nP09 Pastel de chocolate"},
{"type": "mrkdwn", "text": "*Entrega*\nSábado 28 de marzo, 17:00"},
{"type": "mrkdwn", "text": "*Importe*\n460 MXN (catálogo)"},
{"type": "mrkdwn", "text": "*Cliente*\ncli_7f3a9c21e0"},
{"type": "mrkdwn", "text": "*Leyenda*\nFeliz cumpleaños"},
{"type": "mrkdwn", "text": "*Horno ese día*\n3 de 5 pasteles"}
]
},
{
"type": "context",
"elements": [
{"type": "mrkdwn", "text": "Borrador apr_0323_014 · a los 20 min pasa a la pantalla del obrador · caduca en 2 h"}
]
},
{"type": "divider"},
{
"type": "actions",
"block_id": "decision_apr_0323_014",
"elements": [
{
"type": "button",
"action_id": "aprobar",
"text": {"type": "plain_text", "text": "Aprobar"},
"style": "primary",
"value": "apr_0323_014"
},
{
"type": "button",
"action_id": "editar",
"text": {"type": "plain_text", "text": "Editar"},
"value": "apr_0323_014"
},
{
"type": "button",
"action_id": "rechazar",
"text": {"type": "plain_text", "text": "Rechazar"},
"style": "danger",
"value": "apr_0323_014",
"confirm": {
"title": {"type": "plain_text", "text": "¿Rechazar el borrador?"},
"text": {"type": "mrkdwn", "text": "La clienta recibirá un aviso para hablar con Valeria."},
"confirm": {"type": "plain_text", "text": "Rechazar"},
"deny": {"type": "plain_text", "text": "Volver"}
}
}
]
}
]
}
El campo “Horno ese día: 3 de 5 pasteles” responde a la objeción que Don Ernesto hizo el Día 38 (“que no prometa nada que yo no pueda hornear”): quien aprueba ve la ocupación del horno sin abrir otra pantalla. Y el value de los tres botones es el identificador del borrador, apr_0323_014, no la dirección de reanudación del flujo. El Día 38 advirtió que esa dirección funciona como una llave; en un botón de Slack viajaría por los servidores de Slack y quedaría en el historial del canal. El workflow la busca en su base de datos a partir del identificador.
La Figura 2 separa el mensaje en sus capas, con lo que hace cada una.
Figura 2. Anatomía de un mensaje de Block Kit: el texto de notificación, la sección de título, la sección de seis campos, el contexto gris, el divisor y el bloque de acciones con los botones Aprobar, Editar y Rechazar, cada uno con su action_id y el identificador del borrador como valor.
Slack ofrece un constructor visual en el navegador para ver cómo queda un mensaje, pero la serie no usa capturas de pantalla (A.4.1). El siguiente bloque hace algo más útil para un arquitecto: lee el JSON, comprueba los límites y dibuja la maqueta del mensaje a partir del propio archivo, de modo que si alguien cambia el JSON, la maqueta y las comprobaciones cambian con él:
# Lectura del mensaje de Block Kit, comprobación de límites y maqueta dibujada con patches en Python:
mensaje = json.loads((carpeta / 'aprobacion_pastel.json').read_text(encoding = 'utf-8'))
bloques = mensaje['blocks']
campos = [c for b in bloques for c in b.get('fields', [])]
botones = [el for b in bloques if b['type'] == 'actions' for el in b['elements']]
textos = [b['text']['text'] for b in bloques if 'text' in b] + [c['text'] for c in campos]
print('Tipos de bloque:', [b['type'] for b in bloques])
print(f"Bloques: {len(bloques)} de 50 | campos: {len(campos)} de 10 | botones: {len(botones)} de 25 "
f"| texto más largo: {max(len(t) for t in textos)} de 3000")
print('Texto de la notificación:', len(mensaje['text']), 'caracteres')
print('Botones:', [(b['text']['text'], b['action_id'], b.get('style', 'normal')) for b in botones])
fig, ax = plt.subplots(figsize = (9, 6.2))
ax.add_patch(FancyBboxPatch((0.2, 0.2), 9.6, 7.6, boxstyle = 'round,pad=0.02,rounding_size=0.2',
facecolor = 'white', edgecolor = '#d0cfcb', linewidth = 1.5))
ax.add_patch(FancyBboxPatch((0.5, 6.75), 0.75, 0.75, boxstyle = 'round,pad=0.02,rounding_size=0.12',
facecolor = '#2a78d6', edgecolor = 'none'))
ax.text(0.875, 7.12, 'TL', color = 'white', ha = 'center', va = 'center', fontsize = 11, weight = 'bold')
ax.text(1.45, 7.3, 'TL Operaciones', fontsize = 11, weight = 'bold', va = 'center')
ax.text(3.6, 7.3, 'APP 10:40', fontsize = 9, color = '#6b6a66', va = 'center')
y = 6.85
for b in bloques:
if b['type'] == 'section' and 'text' in b:
titulo, resto = b['text']['text'].split('\n', 1)
ax.text(1.45, y, titulo.strip('*'), fontsize = 11, weight = 'bold', va = 'top')
ax.text(1.45, y - 0.42, resto, fontsize = 10, va = 'top')
y -= 1.0
elif b['type'] == 'section':
for i, c in enumerate(b['fields']):
etiqueta, valor = c['text'].split('\n', 1)
fx, fy = 1.45 + (i % 2) * 4.1, y - (i // 2) * 0.82
ax.text(fx, fy, etiqueta.strip('*'), fontsize = 10, weight = 'bold', va = 'top')
ax.text(fx, fy - 0.33, valor, fontsize = 10, va = 'top')
y -= 0.82 * math.ceil(len(b['fields']) / 2) + 0.1
elif b['type'] == 'context':
ax.text(1.45, y, b['elements'][0]['text'], fontsize = 8.5, color = '#6b6a66', va = 'top')
y -= 0.5
elif b['type'] == 'divider':
ax.plot([1.45, 9.4], [y, y], color = '#d0cfcb', linewidth = 1)
y -= 0.35
elif b['type'] == 'actions':
x = 1.45
for boton in b['elements']:
ancho = 0.32 + 0.13 * len(boton['text']['text'])
fondo = {'primary': '#1f9d55', 'danger': '#c8423b'}.get(boton.get('style'), 'white')
ax.add_patch(FancyBboxPatch((x, y - 0.62), ancho, 0.55, boxstyle = 'round,pad=0.02,rounding_size=0.08',
facecolor = fondo, edgecolor = '#b5b4b0', linewidth = 1))
ax.text(x + ancho / 2, y - 0.345, boton['text']['text'], ha = 'center', va = 'center', fontsize = 10,
weight = 'bold', color = 'white' if fondo != 'white' else '#1d1c1d')
x += ancho + 0.25
y -= 0.8
ax.text(5.0, 0.55, f"Notificación del teléfono: \"{mensaje['text'][:58]}...\"", ha = 'center', fontsize = 8.5,
color = '#6b6a66', style = 'italic')
ax.set_xlim(0, 10)
ax.set_ylim(0, 8)
ax.axis('off')
ax.set_title('Mensaje de aprobación en #aprobaciones, dibujado desde su JSON', fontsize = 14)
plt.tight_layout()
plt.show()
Tipos de bloque: ['section', 'section', 'context', 'divider', 'actions']
Bloques: 5 de 50 | campos: 6 de 10 | botones: 3 de 25 | texto más largo: 104 de 3000
Texto de la notificación: 95 caracteres
Botones: [('Aprobar', 'aprobar', 'primary'), ('Editar', 'editar', 'normal'), ('Rechazar', 'rechazar', 'danger')]

El mensaje usa $5$ bloques de $50$, $6$ campos de $10$ y $3$ botones de $25$, y su texto más largo tiene $104$ caracteres de $3,000$. La notificación del teléfono, $95$ caracteres, dice qué se pide, para cuándo y cuánto cuesta, de modo que Valeria puede decidir si abre Slack ahora o en diez minutos sin abrirlo. La maqueta confirma el orden de lectura que se buscaba: qué es, los datos para decidir, el contexto y, al final, la acción. Es el principio de “lo importante primero” del Día 49 aplicado a un mensaje interno.
Aprobaciones con botones
Un botón en Slack no hace nada por sí solo. Cuando alguien lo pulsa, Slack envía una petición HTTP POST a la URL de peticiones (Request URL) que se configura en la sección de interactividad de la aplicación. Para que los botones de un webhook entrante funcionen, la aplicación que lo creó debe tener la interactividad activada con esa URL; en Trigo Lento, la URL apunta al webhook slack-acciones de n8n.
La documentación oficial, revisada en octubre de 2026, fija tres reglas para esa petición:
- Llega como formulario, con
Content-Type: application/x-www-form-urlencodedy un solo parámetro,payload, que contiene el JSON de la interacción: el tipo (block_actions), el usuario que pulsó, la acción (action_idyvalue) y unaresponse_url. - Hay que confirmar en $3$ segundos con un HTTP 200. Si no, la persona ve un error en Slack, aunque el flujo termine bien después. Por eso el workflow responde primero y trabaja después.
- La
response_urladmite hasta $5$ respuestas en $30$ minutos, y sirve para reemplazar el mensaje original. Trigo Lento la usa para cambiar los botones por una línea del tipo “Decisión: aprobar por Valeria a las 10:42”, de modo que nadie más pueda pulsarlos.
La Tabla 3 recoge los cuatro tipos de aprobación de Trigo Lento con el efecto de cada botón. Los tres botones son los mismos en todos los tipos, a propósito: la costumbre de pulsar en el mismo sitio vale más que la precisión de un botón distinto para cada caso.
Tabla 3. Mensajes de aprobación por tipo, con sus botones y el efecto de cada uno.
| Tipo (al mes) | Origen | Aprobar | Editar | Rechazar |
|---|---|---|---|---|
| Borrador de correo a mayorista (120) | Gmail, Día 47 | Etiqueta el borrador como revisado; Valeria lo envía desde Gmail, porque el flujo no tiene permiso de envío | Abre el borrador en Gmail para corregirlo | Descarta el borrador y deja el correo en la bandeja de Valeria |
| Pastel personalizado (40) | Agente de pedidos, Día 38 | Reanuda el Wait con aprobar: el borrador pasa a pedido y el agente confirma a la clienta | Reanuda con editar: Valeria corrige el borrador y el agente no contesta | Reanuda con rechazar: la clienta recibe un aviso para hablar con Valeria |
| Publicación en redes (36) | Módulo 7 | Programa la publicación | Abre el texto para corregirlo | Archiva el borrador |
| Respuesta a queja (26) | Agente de pedidos | Envía la respuesta por WhatsApp dentro de la ventana de 24 horas | Valeria reescribe la respuesta | Valeria llama a la clienta |
Verificar que la pulsación viene de Slack
La URL de peticiones es pública: está en internet para que Slack pueda llamarla. Cualquiera que la descubra podría enviarle un POST con un payload inventado que diga “aprobar apr_0323_014”. Por eso Slack firma cada petición. Con el secreto de firma (signing secret) que solo conocen Slack y la aplicación, calcula un código de autenticación de mensajes basado en hash (o HMAC, por sus siglas en inglés: “Hash-based Message Authentication Code”) sobre una cadena base y lo envía en dos cabeceras:
$$ \texttt{X-Slack-Signature} = \texttt{v0=} ,\Vert, \mathrm{HMAC}_{\mathrm{SHA256}}\big(s,\ \texttt{v0:},t,\texttt{:},b\big) $$
La lógica de la firma es la siguiente:
- $s$ es el secreto de firma de la aplicación, $t$ la marca de tiempo que llega en
X-Slack-Request-Timestamp(segundos desde 1970) y $b$ el cuerpo crudo de la petición, tal como llegó, antes de interpretar el formulario o el JSON. - $\Vert$ significa concatenar: el resultado es el prefijo
v0=seguido del HMAC en hexadecimal. El prefijov0es la versión del esquema de firma. - Quien no conoce $s$ no puede producir una firma válida para un cuerpo distinto: cambiar una sola letra de $b$ cambia por completo el HMAC.
- Como $t$ forma parte de lo firmado, una petición capturada no se puede “rejuvenecer” cambiando su marca de tiempo. Slack pide rechazar toda petición cuya marca se aleje más de $5$ minutos del reloj propio.
El siguiente bloque implementa la verificación con hmac y hashlib y la pone a prueba con cuatro casos: la petición original, la misma con el cuerpo alterado (un atacante que cambia aprobar por rechazar), una petición correctamente firmada pero de hace seis minutos y la original enviada por segunda vez. La comparación usa hmac.compare_digest, que tarda lo mismo acierte o falle, para no dar pistas por el tiempo de respuesta. El secreto del ejemplo es inventado y lo dice en su propio valor:
# Verificación de la firma de Slack con HMAC-SHA256, tolerancia de 5 minutos y control de repeticiones en Python:
SECRETO = 'EJEMPLO-NO-REAL-8f2c1d' # en producción: variable de entorno, nunca en el código
TOLERANCIA = 300 # 5 minutos
vistas = set()
def firmar(secreto, marca, cuerpo):
base = f'v0:{marca}:{cuerpo}'.encode('utf-8')
return 'v0=' + hmac.new(secreto.encode('utf-8'), base, hashlib.sha256).hexdigest()
def verificar(secreto, marca, cuerpo, firma, ahora):
if abs(ahora - int(marca)) > TOLERANCIA:
return 'rechazada: marca de tiempo vieja'
if not hmac.compare_digest(firmar(secreto, marca, cuerpo), firma):
return 'rechazada: firma no coincide'
if firma in vistas:
return 'rechazada: petición repetida'
vistas.add(firma)
return 'válida'
ahora = int(datetime(2026, 3, 23, 10, 42, tzinfo = CDMX).timestamp())
carga = {'type': 'block_actions', 'user': {'id': 'U0EJEMPLO01'},
'actions': [{'action_id': 'aprobar', 'value': 'apr_0323_014'}],
'response_url': 'https://hooks.slack.com/actions/EJEMPLO-NO-REAL'}
cuerpo = urlencode({'payload': json.dumps(carga, separators = (',', ':'))})
firma = firmar(SECRETO, ahora, cuerpo)
vieja = ahora - 360
casos = [('Original', ahora, cuerpo, firma),
('Cuerpo alterado', ahora, cuerpo.replace('aprobar', 'rechazar'), firma),
('Marca de hace 6 min', vieja, cuerpo, firmar(SECRETO, vieja, cuerpo)),
('Repetida', ahora, cuerpo, firma)]
print('Cuerpo:', cuerpo[:70] + '...', f'({len(cuerpo)} caracteres)')
print('Firma: ', firma[:22] + '...')
for nombre, marca, c, f in casos:
print(f'{nombre:<20} -> {verificar(SECRETO, marca, c, f, ahora)}')
accion = json.loads(parse_qs(cuerpo)['payload'][0])['actions'][0]
print('Acción leída tras verificar:', accion['action_id'], accion['value'])
Cuerpo: payload=%7B%22type%22%3A%22block_actions%22%2C%22user%22%3A%7B%22id%22... (280 caracteres)
Firma: v0=013f405d67fb76da96a...
Original -> válida
Cuerpo alterado -> rechazada: firma no coincide
Marca de hace 6 min -> rechazada: marca de tiempo vieja
Repetida -> rechazada: petición repetida
Acción leída tras verificar: aprobar apr_0323_014
Los cuatro casos terminan como deben: la original es válida y las otras tres se rechazan, cada una por su motivo. El cuerpo alterado falla la firma aunque la marca de tiempo sea correcta; la petición de hace seis minutos tiene una firma perfecta, pero está fuera de la tolerancia de $300$ segundos; y la repetida se detecta porque su firma ya se vio. Solo después de verificar se interpreta el formulario y se lee la acción: aprobar sobre apr_0323_014.
El orden importa más de lo que parece. La firma se calcula sobre el cuerpo crudo: si n8n interpretara primero el formulario y el bloque reconstruyera el cuerpo a partir de los campos, bastaría una diferencia de codificación (un espacio como + o como %20) para que una petición auténtica fallara. Por eso el webhook de n8n del Bloque 10 activa la opción de cuerpo crudo.
Sobre las repeticiones, una aclaración honesta: Slack no exige rechazar firmas repetidas, solo marcas viejas. Dentro de los cinco minutos de tolerancia, una petición capturada podría reenviarse. En el bloque, el conjunto vistas lo impide en memoria; en el workflow real, la protección es más sólida y está en la base de datos: la decisión solo se registra si el borrador todavía no tiene decisión, de modo que una segunda pulsación, auténtica o repetida, no cambia nada.
Reanudar el flujo detenido
El Día 38 dejó al asistente de pedidos esperando en un nodo Wait con resume en webhook: la ejecución queda guardada en la base de n8n hasta que alguien llama a su dirección única, $execution.resumeUrl, con ?decision=aprobar o ?decision=rechazar. Hasta hoy, esos enlaces viajaban por WhatsApp al teléfono de Valeria. A partir de hoy, el flujo del Día 38 hace dos cambios pequeños: guarda la dirección de reanudación en la tabla aprobaciones de Postgres, junto al identificador del borrador, y publica el mensaje de Block Kit en #aprobaciones en lugar del WhatsApp.
El botón ya no lleva la llave; lleva el número del borrador. El workflow TL Slack operaciones v1 recibe la pulsación, verifica la firma, busca el borrador y, si nadie ha decidido todavía, llama a la dirección de reanudación. La Figura 3 dibuja el recorrido completo.
Figura 3. El botón que reanuda: el flujo del asistente de pedidos detenido en el hexágono gris del nodo Wait, el botón Aprobar en Slack, la petición firmada con HMAC que llega a n8n en menos de 3 segundos, la verificación y el registro en Postgres, y la llamada a la dirección de reanudación que despierta el flujo.
n8n ofrece además, en su nodo Slack, una operación “Send and Wait for Response” que publica un mensaje con botones de aprobación y detiene el flujo hasta que alguien responde; la documentación de n8n, revisada en octubre de 2026, la marca todavía como función en vista previa. Trigo Lento no la usa por dos razones: el asistente de pedidos ya tiene su propio Wait desde el Día 38 y cambiarlo obligaría a rehacer el flujo, y una aplicación de Slack tiene una sola URL de peticiones para toda su interactividad, así que conviene que un único workflow reciba todas las pulsaciones y decida a dónde van.
Cuánto tarda una aprobación
Con los botones en su sitio, la pregunta del Día 38 vuelve con más detalle: ¿cuánto tarda Valeria en decidir, y depende de la hora? Depende mucho. De 7:00 a 10:00 ayuda en el obrador con las manos en la masa; de 10:00 a 12:00 está la hora pico de mensajes; de 12:00 a 15:00 está en la oficina, con el teléfono delante; por la tarde, entre el mostrador y las cuentas. Un modelo exponencial con una sola media, como el del Día 38, no ve esas diferencias. Hoy el tiempo de respuesta se modela por franja con una distribución lognormal: el logaritmo del tiempo sigue una normal, lo que produce muchos tiempos cortos y una cola larga de esperas, como cuando un mensaje llega justo al meter la hornada.
Si $R$ es el tiempo que tarda Valeria en una franja con mediana $m$ y dispersión $\sigma$, y a los $r$ minutos el pastel aparece también en la pantalla del obrador, donde Don Ernesto responde con media $m_E$, la fracción de aprobaciones resueltas antes de $T$ minutos es:
$$ P(R \le T) = 1 - \Big[1 - \Phi\Big(\frac{\ln T - \ln m}{\sigma}\Big)\Big] , e^{-\max(T - r,, 0)/m_E} $$
La lógica detrás de esta fórmula es la siguiente:
- $\Phi$ es la función de distribución de la normal estándar; $\Phi\big((\ln T - \ln m)/\sigma\big)$ es la probabilidad de que Valeria responda antes de $T$ minutos. En la mediana, $T = m$, vale exactamente $0.5$.
- El corchete es la probabilidad de que Valeria no haya respondido; el exponencial, la de que Don Ernesto tampoco, contando solo desde que el pastel llegó a su pantalla. Decide el primero que responde, así que se multiplican las dos probabilidades de silencio.
- Para los tipos sin relevo (correos, publicaciones y quejas), el exponencial vale $1$ y la fórmula se reduce a la de Valeria sola.
- Con $r = 20$ y $m_E = 15$, los mismos supuestos del Día 38, el relevo no cambia nada antes del minuto $20$ y ayuda más cuanto peor es la franja.
El siguiente bloque simula $10,000$ aprobaciones con el reparto de tipos del mes y las cuatro franjas, contrasta la simulación con la fórmula para un objetivo de $15$ minutos, mide el efecto del relevo en los pasteles y el nivel de servicio de los correos de Marisol, cuyo acuerdo es una respuesta en menos de $60$ minutos en horario laboral. Al final aplica la ley de Little, $L = \lambda W$, que relaciona el ritmo de llegada $\lambda$, el tiempo medio de espera $W$ y el número medio $L$ de aprobaciones esperando en el canal:
# Simulación del tiempo hasta la aprobación por franja horaria y efecto del relevo en Python:
FRANJAS = pd.DataFrame({
'franja': ['07-10 obrador', '10-12 hora pico', '12-15 oficina', '15-19 tarde'],
'peso': [0.25, 0.30, 0.25, 0.20], # parte de las aprobaciones que llega en cada franja
'mediana': [18.0, 12.0, 5.0, 7.0], # minutos que tarda Valeria (supuestos)
'sigma': [0.8, 0.9, 0.6, 0.7]}) # dispersión del logaritmo del tiempo
RELEVO, M_ERNESTO = 20, 15 # a los 20 min el pastel pasa a la pantalla del obrador
def cdf_lognormal(t, mediana, sigma):
return 0.5 * (1 + math.erf((math.log(t) - math.log(mediana)) / (sigma * math.sqrt(2))))
def simular(n, rng):
tipos = rng.choice(list(APROBACIONES), size = n, p = np.array(list(APROBACIONES.values())) / 222)
franja = rng.choice(len(FRANJAS), size = n, p = FRANJAS.peso)
valeria = rng.lognormal(np.log(FRANJAS.mediana.values[franja]), FRANJAS.sigma.values[franja])
ernesto = RELEVO + rng.exponential(M_ERNESTO, n)
con_relevo = np.where(tipos == 'pastel', np.minimum(valeria, ernesto), valeria)
quien = np.where((tipos == 'pastel') & (ernesto < valeria), 'Don Ernesto', 'Valeria')
return pd.DataFrame({'tipo': tipos, 'franja': FRANJAS.franja.values[franja], 'solo_valeria': valeria,
'minutos': con_relevo, 'persona': quien})
sim = simular(10_000, rng)
resumen = sim.groupby('franja').agg(n = ('minutos', 'size'), mediana = ('solo_valeria', 'median'),
p90 = ('solo_valeria', lambda s: s.quantile(0.9)),
en_15_sim = ('solo_valeria', lambda s: (s <= 15).mean()))
resumen = resumen.reindex(FRANJAS.franja) # mismo orden que la tabla de supuestos
resumen['en_15_formula'] = [cdf_lognormal(15, m, s) for m, s in zip(FRANJAS.mediana, FRANJAS.sigma)]
print(resumen.round(3).to_string())
print(f"Todas en 15 min: {(sim.minutos <= 15).mean():.1%} | media: {sim.minutos.mean():.1f} min")
pasteles = sim[sim.tipo == 'pastel']
print(f"Pasteles en 30 min: solo Valeria {(pasteles.solo_valeria <= 30).mean():.1%} | "
f"con relevo {(pasteles.minutos <= 30).mean():.1%} | los decide Don Ernesto: {(pasteles.persona == 'Don Ernesto').mean():.1%}")
correos = sim[sim.tipo == 'correo_mayorista']
print(f"Correos de Marisol aprobados en 60 min: {(correos.minutos <= 60).mean():.1%}")
lam = 222 / (26 * 12) # aprobaciones por hora de trabajo
print(f"Ley de Little: lambda = {lam:.2f} por hora | W = {sim.minutos.mean() / 60:.3f} h | "
f"L = {lam * sim.minutos.mean() / 60:.2f} aprobaciones esperando en promedio")
mes = simular(222, rng) # un mes concreto para el registro del Bloque 9
fig, ax = plt.subplots(figsize = (9, 4.8))
t = np.linspace(0.5, 120, 400)
colores = ['#eb6834', '#c8423b', '#1f9d55', '#2a78d6']
for franja, color in zip(FRANJAS.franja, colores):
grupo = sim[sim.franja == franja]
ax.plot(t, [(grupo.minutos <= x).mean() * 100 for x in t], color = color, linewidth = 2, label = franja)
ax.plot(t, [(sim.minutos <= x).mean() * 100 for x in t], color = '#1d1c1d', linewidth = 2.5, linestyle = '--',
label = 'Todas las franjas')
ax.axvline(15, color = '#6b6a66', linestyle = ':', linewidth = 1.5)
ax.text(16, 8, 'Objetivo: 15 min', fontsize = 9, color = '#6b6a66')
ax.axvline(60, color = '#6b6a66', linestyle = ':', linewidth = 1.5)
ax.text(61, 8, 'SLA de Marisol: 60 min', fontsize = 9, color = '#6b6a66')
ax.set_xlabel('Minutos desde que se publica la solicitud')
ax.set_ylabel('Aprobaciones resueltas (%)')
ax.set_title('Tiempo hasta la aprobación por franja horaria', fontsize = 14)
ax.legend(fontsize = 9, loc = 'lower right')
ax.grid(alpha = 0.3)
plt.tight_layout()
plt.show()
n mediana p90 en_15_sim en_15_formula
franja
07-10 obrador 2473 17.816 49.535 0.412 0.410
10-12 hora pico 3088 12.058 38.464 0.602 0.598
12-15 oficina 2500 5.033 10.935 0.962 0.966
15-19 tarde 1939 6.809 16.749 0.868 0.862
Todas en 15 min: 69.7% | media: 14.4 min
Pasteles en 30 min: solo Valeria 88.6% | con relevo 94.5% | los decide Don Ernesto: 11.3%
Correos de Marisol aprobados en 60 min: 97.0%
Ley de Little: lambda = 0.71 por hora | W = 0.240 h | L = 0.17 aprobaciones esperando en promedio

La simulación coincide con la fórmula con diferencias de menos de un punto en las cuatro franjas, y la gráfica cuenta cuatro cosas:
- En primer lugar, la franja decide más que la persona. De 12:00 a 15:00, Valeria resuelve el $96.2%$ en $15$ minutos; de 7:00 a 10:00, solo el $41.2%$, con una mediana de $17.8$ minutos y un percentil $90$ de $49.5$. Es la misma Valeria con el mismo teléfono; lo que cambia es dónde tiene las manos.
- En segundo lugar, en conjunto, el $69.7%$ de las aprobaciones se resuelve en $15$ minutos, con una media de $14.4$ minutos. La línea discontinua de la gráfica es la que verá Valeria en su cuadro de mando del módulo 7.
- En tercer lugar, el relevo sube los pasteles del $88.6%$ al $94.5%$ en $30$ minutos, y Don Ernesto decide el $11.3%$ de ellos. Las cifras son algo más altas que las del Día 38 ($85.4%$ y $92.5%$) porque el modelo por franjas es más fino que la mezcla de dos medias, pero la conclusión no cambia: sin relevo no se llega a la meta del $90%$; con relevo, sí.
- En cuarto lugar, los correos de Marisol se aprueban en $60$ minutos el $97.0%$ de las veces. El acuerdo con Brújula se cumple con holgura incluso sin relevo, siempre que los correos de los lunes a las 7:40 no se queden para después de la hornada; las franjas de la mañana son las que pueden romperlo.
La ley de Little pone el último número: con $\lambda = 0.71$ aprobaciones por hora de trabajo y $W = 0.240$ horas, en #aprobaciones espera en promedio $L = 0.17$ solicitudes. Casi siempre el canal está vacío o tiene una sola. Eso es lo que hace que funcione: un canal de aprobaciones con veinte solicitudes acumuladas deja de mirarse, y uno que casi siempre está limpio se revisa en cuanto suena.
Fatiga de alertas
La fatiga de alertas es lo que ocurre cuando un canal de avisos recibe tantos mensajes que las personas dejan de leerlos, y con ellos, el aviso importante. El libro de ingeniería de confiabilidad de Google lo resume en una regla que se aplica igual a una panadería: cada aviso que interrumpe a una persona debe ser urgente, accionable y nuevo; lo que no cumple las tres condiciones va a un resumen o a un registro. El día de los $51$ avisos, Valeria leyó los primeros cinco y silenció el chat; el aviso de que la factura de Brújula no había salido llegó el número $44$.
Trigo Lento aplica tres herramientas, en este orden:
- Severidades. Cada causa de error tiene una: crítica (el token de Meta caduca, el tope de gasto se alcanza), que se publica siempre y a cualquier hora; alta (el webhook de WhatsApp responde con errores 5xx, la factura tarda), que se publica en horario laboral; y baja (un 429 de la API de Claude que el SDK ya reintentó, la cuota de una hoja de cálculo), que no interrumpe nunca y va al resumen.
- Sesiones de $10$ minutos. El primer error de una causa abre un aviso; los errores de la misma causa que llegan a menos de $10$ minutos del anterior se suman a ese aviso como un contador en su hilo, que no vuelve a notificar. La sesión se cierra tras $10$ minutos de silencio.
- Resumen de las 7:00. Lo bajo y lo nocturno no crítico se reúne en un solo mensaje diario, a la hora en que empieza el turno.
¿Cuánto comprime una sesión? Si los errores de una causa llegan como un proceso de Poisson con ritmo $\lambda$ por minuto, un error abre aviso nuevo solo si el anterior llegó hace más de $\Delta$ minutos, y el número esperado de avisos en un periodo de $T$ minutos es:
$$ E[A] \approx \lambda T , e^{-\lambda \Delta} $$
La lógica detrás de esta fórmula es la siguiente:
- $\lambda T$ es el número esperado de errores en el periodo; cada uno se convierte en aviso solo si el hueco desde el error anterior supera $\Delta$.
- En un proceso de Poisson, los huecos entre errores son exponenciales, y la probabilidad de un hueco mayor que $\Delta$ es $e^{-\lambda \Delta}$: esa es la fracción de errores que abre aviso.
- En una ráfaga de $40$ errores en $50$ minutos, $\lambda = 0.8$ por minuto y, con $\Delta = 10$, $e^{-8} = 0.0003$: prácticamente toda la ráfaga cabe en un aviso.
- Con errores sueltos, como $3$ al día de la misma causa ($\lambda = 0.0021$ por minuto), $e^{-0.021} = 0.98$: casi cada error sigue siendo un aviso, y está bien que lo sea. La sesión comprime las ráfagas y respeta los errores aislados, que es lo que se quiere.
El siguiente bloque simula un mes de $30$ días con seis causas de error, cada una con su severidad y su ritmo diario, más tres incidentes: $40$ errores del webhook de WhatsApp en $50$ minutos el día $9$, $25$ errores 429 el día $16$ y $18$ retrasos de facturación el día $23$. Después aplica las tres herramientas y compara los mensajes por día antes y después:
# Agrupación de alertas por sesiones de 10 minutos y severidad en un mes simulado en Python:
CAUSAS = pd.DataFrame({
'causa': ['webhook_5xx', 'limite_429', 'factura_timeout', 'cuota_hojas', 'token_meta', 'tope_gasto'],
'severidad': ['alta', 'baja', 'alta', 'baja', 'critica', 'critica'],
'por_dia': [3.0, 4.0, 0.8, 1.5, 0.05, 0.03]})
DIAS, HUECO = 30, 10 # mes de 30 días y sesiones de 10 minutos
filas = []
for d in range(DIAS):
for c in CAUSAS.itertuples():
for minuto in rng.uniform(0, 1440, rng.poisson(c.por_dia)):
filas.append((d, c.causa, c.severidad, minuto))
for d, causa, n, inicio in [(8, 'webhook_5xx', 40, 600), (15, 'limite_429', 25, 660), (22, 'factura_timeout', 18, 540)]:
for minuto in inicio + np.sort(rng.uniform(0, 50, n)): # incidentes: ráfagas de la misma causa
filas.append((d, causa, 'alta' if causa != 'limite_429' else 'baja', minuto))
errores = pd.DataFrame(filas, columns = ['dia', 'causa', 'severidad', 'minuto']).sort_values(['dia', 'minuto'])
def agrupar(errores, hueco = HUECO):
# Un aviso abre sesión; los errores de la misma causa a menos de 'hueco' minutos se suman a ella.
# Lo bajo y lo nocturno no crítico va al resumen de las 7:00 (un mensaje al día):
avisos, ultimo = [], {}
for e in errores.itertuples():
en_horario = 7 * 60 <= e.minuto < 19 * 60
if e.severidad == 'baja' or (not en_horario and e.severidad != 'critica'):
continue
clave = (e.dia, e.causa)
if clave not in ultimo or e.minuto - ultimo[clave] > hueco:
avisos.append((e.dia, e.causa, e.severidad))
ultimo[clave] = e.minuto
resumenes = [(d, 'resumen_7', 'resumen') for d in range(DIAS)]
return pd.DataFrame(avisos + resumenes, columns = ['dia', 'causa', 'severidad'])
avisos = agrupar(errores)
antes, despues = errores.groupby('dia').size().reindex(range(DIAS), fill_value = 0), avisos.groupby('dia').size()
tabla3 = pd.DataFrame({'antes': errores.severidad.value_counts(), 'despues': avisos.severidad.value_counts()}).fillna(0).astype(int)
tabla3.loc['total'] = tabla3.sum()
print(tabla3.to_string())
print(f'Máximo diario: antes {antes.max()} | después {despues.max()}')
print(f'Días por encima de 15 alertas: antes {(antes > 15).sum()} | después {(despues > 15).sum()}')
print(f"Día 9 (40 errores de webhook_5xx en 50 min): {len(avisos[(avisos.dia == 8) & (avisos.causa == 'webhook_5xx')])} aviso(s) de esa causa")
for lam_min in [0.8, 3.0 / 1440]:
print(f'lambda = {lam_min:.4f} por min -> fracción de errores que abren aviso: {math.exp(-lam_min * HUECO):.4f}')
fig, ax = plt.subplots(figsize = (10, 4.6))
x = np.arange(1, DIAS + 1)
ax.bar(x - 0.2, antes.values, width = 0.4, color = '#b5b4b0', label = 'Antes: un mensaje por error')
ax.bar(x + 0.2, despues.values, width = 0.4, color = '#2a78d6', label = 'Después: sesiones de 10 min y resumen')
ax.axhline(15, color = '#c8423b', linestyle = '--', linewidth = 1.5)
ax.text(DIAS + 0.6, 16, 'Máximo tolerable: 15', color = '#c8423b', ha = 'right', fontsize = 9)
ax.set_xlabel('Día del mes')
ax.set_ylabel('Mensajes en #alertas')
ax.set_title('Alertas por día antes y después de agrupar', fontsize = 14)
ax.legend(fontsize = 9, loc = 'upper left')
ax.grid(axis = 'y', alpha = 0.3)
plt.tight_layout()
plt.show()
antes despues
severidad
alta 170 61
baja 189 0
critica 1 1
resumen 0 30
total 360 92
Máximo diario: antes 51 | después 6
Días por encima de 15 alertas: antes 3 | después 0
Día 9 (40 errores de webhook_5xx en 50 min): 1 aviso(s) de esa causa
lambda = 0.8000 por min -> fracción de errores que abren aviso: 0.0003
lambda = 0.0021 por min -> fracción de errores que abren aviso: 0.9794

El mes simulado tenía $360$ errores, y en el esquema de un mensaje por error habrían sido $360$ avisos, con un máximo de $51$ en un día y tres días por encima de las $15$ alertas que Valeria considera el máximo tolerable. Con las tres herramientas quedan $92$ mensajes: $61$ avisos de severidad alta, $1$ crítico y $30$ resúmenes de las 7:00, con un máximo de $6$ por día y ningún día por encima de $15$. La Tabla 4 lo desglosa.
Tabla 4. Alertas antes y después de agrupar en un mes simulado de 30 días.
| Severidad | Antes (un mensaje por error) | Después | Qué pasó con ellas |
|---|---|---|---|
| Alta | 170 | 61 | Agrupadas en sesiones de 10 minutos; las nocturnas pasan al resumen |
| Baja | 189 | 0 | Ninguna interrumpe: todas van al resumen de las 7:00 |
| Crítica | 1 | 1 | Se publica siempre, a cualquier hora |
| Resumen de las 7:00 | 0 | 30 | Un mensaje al día con lo bajo y lo nocturno |
| Total | 360 | 92 | Reducción del 74.4 % |
| Máximo en un día | 51 | 6 | El día 9, los 40 errores del webhook son 1 aviso |
| Días por encima de 15 | 3 | 0 | Umbral tolerable fijado por Valeria |
Tres lecturas de la gráfica y de la tabla:
- La ráfaga del día $9$ se convierte en un aviso. Los $40$ errores del webhook de WhatsApp en $50$ minutos abren una sola sesión, como predijo la fórmula, y el aviso dice “webhook_5xx: 40 errores desde las 10:00” en su hilo. Valeria lo lee una vez y sabe que es un incidente, no cuarenta.
- Los días normales casi no cambian de forma, cambian de escala. Un día sin incidentes tenía entre $4$ y $15$ mensajes y pasa a tener entre $1$ y $6$, porque los errores de severidad baja dejan de interrumpir.
- La cifra final, unos $3$ mensajes por día, coincide con el objetivo de la Tabla 1 ($104$ al mes para
#alertas). Un objetivo escrito antes de medir, y una medición que lo confirma, es la mejor señal de que el canal se va a mirar.
Agrupar también ayuda con el límite de tasa. Sin agrupación, la ráfaga del día $9$ intentaría publicar $40$ mensajes en $50$ minutos, que el límite de un mensaje por segundo admitiría de sobra; el riesgo real está en los fallos en cascada, cuando un error de red hace fallar a la vez varios flujos y cada uno intenta avisar. Con sesiones, el número de mensajes depende del número de causas, no del número de errores, y ese número es pequeño por construcción.
Don Ernesto sin Slack
Queda la objeción del principio, y la respuesta es aceptarla. Don Ernesto no va a instalar Slack, y no necesita hacerlo: lo que él necesita del centro de operaciones son tres cosas concretas, y ninguna exige una aplicación nueva en su teléfono.
- Saber qué se hornea hoy. En el obrador hay una tableta en la pared que muestra una página de n8n con la lista del día: producto, cantidad y hora, sin nombres ni teléfonos. Es la misma información de
#pedidos-del-dia, en letra grande y sin notificaciones. - Decidir los pasteles cuando Valeria no puede. Cuando un pastel lleva $20$ minutos sin decisión, aparece en la parte de arriba de la pantalla con los mismos tres botones, que llaman al mismo workflow con la misma regla: decide el primero. Este es el relevo que el Día 38 dejó pendiente.
- Empezar el día informado. A las 7:00 recibe un resumen corto por WhatsApp: encargos del día, pasteles, el pedido de Brújula, lo que espera su visto bueno y si la noche dejó algo para el obrador.
El resumen por WhatsApp es comunicación interna con un trabajador, y por eso tiene sus propias reglas: Don Ernesto dio su consentimiento para recibirlo, se envía solo en horario laboral, no lleva datos de clientes y él puede pedir que se detenga cuando quiera, como cualquier otra persona del equipo.
El siguiente bloque escribe las decisiones del mes simulado en la tabla aprobaciones de una copia de la base (persona, tipo, decisión, minutos y hora), consulta quién decidió qué, compone la lista de la pantalla del obrador para el martes 24 de marzo y redacta el resumen de las 7:00:
# Registro de decisiones en SQLite, pantalla del obrador y resumen de las 7:00 para Don Ernesto en Python:
ruta_bd = carpeta / 'trigo_lento_dia50.sqlite'
ruta_bd.unlink(missing_ok = True)
con = sqlite3.connect(ruta_bd)
con.execute('''CREATE TABLE aprobaciones (id TEXT PRIMARY KEY, tipo TEXT, persona TEXT,
decision TEXT, minutos REAL, decidida_en TEXT)''')
decisiones = rng.choice(['aprobar', 'editar', 'rechazar'], size = len(mes), p = [0.88, 0.08, 0.04])
inicio_mes = datetime(2026, 3, 2, 9, 0, tzinfo = CDMX)
registros = [(f'apr_{i:04d}', f.tipo, f.persona, dec, round(f.minutos, 1),
(inicio_mes + timedelta(hours = 12 * i / 222 * 26)).isoformat(timespec = 'minutes'))
for i, (f, dec) in enumerate(zip(mes.itertuples(), decisiones))]
con.executemany('INSERT INTO aprobaciones VALUES (?, ?, ?, ?, ?, ?)', registros)
con.commit()
consulta = pd.read_sql_query('''SELECT persona, tipo, COUNT(*) AS n, ROUND(AVG(minutos), 1) AS media_min,
SUM(decision = 'rechazar') AS rechazos
FROM aprobaciones GROUP BY persona, tipo ORDER BY persona, n DESC''', con)
print(consulta.to_string(index = False))
con.close()
# Pantalla del obrador: lista del martes con producto, cantidad y hora, sin nombres ni teléfonos:
catalogo = {'P01': 'Hogaza', 'P02': 'Baguette', 'P05': 'Croissant', 'P06': 'Rol de canela',
'P08': 'Pastel de zanahoria', 'P09': 'Pastel de chocolate', 'P12': 'Pay de queso'}
claves = rng.choice(list(catalogo), size = 18, p = [0.25, 0.15, 0.2, 0.15, 0.08, 0.1, 0.07])
horas = np.sort(rng.integers(9, 19, 18))
lista = pd.DataFrame({'hora': [f'{h}:00' for h in horas], 'clave': claves, 'cantidad': rng.integers(1, 4, 18)})
lista = pd.concat([pd.DataFrame({'hora': ['7:30'], 'clave': ['P02'], 'cantidad': [40]}), lista])
por_producto = lista.groupby('clave').cantidad.sum().rename(index = catalogo).sort_values(ascending = False)
print('Pantalla del obrador, martes 24 de marzo:', ', '.join(f'{p} {n}' for p, n in por_producto.items()))
hornos = (lista.clave.isin(['P08', 'P09', 'P12'])).sum()
nocturnos = errores[(errores.dia == 22) & ((errores.minuto < 7 * 60) | (errores.minuto >= 19 * 60))]
resumen_7 = (f"Buenos días, Don Ernesto. Martes 24: {len(lista)} encargos, {hornos} pasteles o pays. "
f"Brújula: 40 baguettes a las 7:30. Pendientes de su visto bueno: 1 pastel. "
f"Noche: {len(nocturnos)} avisos técnicos, ninguno para el obrador.")
print(resumen_7)
print(len(resumen_7), 'caracteres | se envía a las 7:00, dentro del horario laboral:',
HORARIO[0] <= 7 < HORARIO[1])
persona tipo n media_min rechazos
Don Ernesto pastel 5 23.7 1
Valeria correo_mayorista 117 14.6 8
Valeria pastel 48 9.9 3
Valeria publicacion 32 19.1 1
Valeria queja 20 22.9 0
Pantalla del obrador, martes 24 de marzo: Baguette 47, Rol de canela 8, Croissant 7, Hogaza 5, Pastel de chocolate 3, Pay de queso 3
Buenos días, Don Ernesto. Martes 24: 19 encargos, 3 pasteles o pays. Brújula: 40 baguettes a las 7:30. Pendientes de su visto bueno: 1 pastel. Noche: 2 avisos técnicos, ninguno para el obrador.
193 caracteres | se envía a las 7:00, dentro del horario laboral: True
La consulta devuelve el libro de actas del mes: Don Ernesto decidió $5$ pasteles con una media de $23.7$ minutos (siempre más de $20$, porque solo los ve después del relevo) y rechazó uno; Valeria decidió los otros $217$, con medias que van de $9.9$ minutos en los pasteles a $22.9$ en las quejas, que son las que más piensa antes de contestar. En este mes concreto llegaron $53$ pasteles en lugar de los $40$ previstos: un mes real tiene el azar de un mes real, y el diseño debe aguantarlo.
La pantalla del obrador para el martes muestra $47$ baguettes (las $40$ de Brújula y $7$ de encargos sueltos), $8$ roles de canela, $7$ croissants, $5$ hogazas, $3$ pasteles de chocolate y $3$ pays de queso. El resumen de las 7:00 tiene $193$ caracteres y se lee en diez segundos, con las manos todavía limpias. No dice el nombre de ninguna clienta, porque Don Ernesto no lo necesita para hornear.
Don Ernesto lee el resumen de prueba en el cuaderno de Valeria y pone su condición: “Si la tableta me pide algo, que sea solo lo del horno. Las facturas y los correos de la señora Marisol no me los ponga ahí, que para eso está usted.” Es una regla de diseño, y queda escrita: la pantalla del obrador solo muestra los pasteles en relevo, nunca los correos ni las quejas.
TL Slack operaciones v1
Todas las piezas del día se juntan en un workflow de n8n de diez nodos, que recibe todas las pulsaciones de la aplicación TL Operaciones:
- Acción de Slack (
webhook): recibe elPOSTde Slack en la rutaslack-acciones, con la opción de cuerpo crudo activa y la respuesta delegada a un nodo posterior (responseModeenresponseNode). - Verificar firma (
code): lee el secreto de$env.TL_SLACK_SIGNING_SECRET, calcula el HMAC sobre el cuerpo crudo, compara concrypto.timingSafeEqual, comprueba la tolerancia de $300$ segundos y, solo si todo cuadra, interpreta elpayload. - ¿Firma válida? (
if): separa las peticiones auténticas de las demás. - Responder 200 y Responder 401 (
respondToWebhook): contestan a Slack antes de hacer nada más, dentro de los $3$ segundos. - Registrar decisión (
postgres): una sola sentenciaUPDATE ... WHERE decision IS NULL RETURNINGque registra la decisión, la persona y la hora solo si nadie ha decidido antes, y devuelve la dirección de reanudación. - ¿Primera decisión? (
if): si la sentencia devolvió una fila, sigue; si no, alguien se adelantó. - Reanudar aprobación, Actualizar mensaje y Avisar ya decidido (
httpRequest): llaman a la dirección de reanudación del Wait, cambian el mensaje original por la línea con la decisión y, en el caso de una segunda pulsación, avisan solo a quien pulsó (ephemeral) de que su acción no se aplicó.
La sentencia del paso 5 es la que hace seguro el relevo. Valeria y Don Ernesto pueden pulsar Aprobar casi a la vez, una en Slack y el otro en la tableta; la base de datos ejecuta las dos sentencias una detrás de otra, la primera encuentra decision IS NULL y la segunda ya no. No hace falta coordinar a las personas: basta con que la decisión sea una operación atómica.
El JSON exportado, con identificadores de ejemplo y sin secretos, es el siguiente:
{
"name": "TL Slack operaciones v1",
"nodes": [
{
"parameters": {"httpMethod": "POST", "path": "slack-acciones", "responseMode": "responseNode", "options": {"rawBody": true}},
"id": "5a500000-0000-4000-8000-000000000001",
"name": "Acción de Slack",
"type": "n8n-nodes-base.webhook",
"typeVersion": 2,
"position": [0, 0],
"webhookId": "00000000-0000-4000-8000-0000000000b5"
},
{
"parameters": {"jsCode": "const crypto = require('crypto');\nconst secreto = $env.TL_SLACK_SIGNING_SECRET;\nconst cabeceras = $input.first().json.headers;\nconst marca = Number(cabeceras['x-slack-request-timestamp'] || 0);\nconst firma = String(cabeceras['x-slack-signature'] || '');\nconst crudo = (await this.helpers.getBinaryDataBuffer(0, 'data')).toString('utf8');\nconst ahora = Math.floor(Date.now() / 1000);\nconst esperada = 'v0=' + crypto.createHmac('sha256', secreto).update(`v0:${marca}:${crudo}`).digest('hex');\nconst vigente = Math.abs(ahora - marca) <= 300;\nconst igual = firma.length === esperada.length && crypto.timingSafeEqual(Buffer.from(firma), Buffer.from(esperada));\nif (!vigente || !igual) return [{ json: { valida: false } }];\nconst carga = JSON.parse(new URLSearchParams(crudo).get('payload'));\nconst accion = carga.actions[0];\nreturn [{ json: { valida: true, decision: accion.action_id, aprobacion: accion.value, usuario_slack: carga.user.id, response_url: carga.response_url } }];"},
"id": "5a500000-0000-4000-8000-000000000002",
"name": "Verificar firma",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [220, 0]
},
{
"parameters": {"conditions": {"options": {"caseSensitive": true, "leftValue": "", "typeValidation": "strict", "version": 2}, "conditions": [{"id": "c5000001", "leftValue": "={{ $json.valida }}", "rightValue": "", "operator": {"type": "boolean", "operation": "true", "singleValue": true}}], "combinator": "and"}, "options": {}},
"id": "5a500000-0000-4000-8000-000000000003",
"name": "¿Firma válida?",
"type": "n8n-nodes-base.if",
"typeVersion": 2.2,
"position": [440, 0]
},
{
"parameters": {"respondWith": "noData", "options": {}},
"id": "5a500000-0000-4000-8000-000000000004",
"name": "Responder 200",
"type": "n8n-nodes-base.respondToWebhook",
"typeVersion": 1.1,
"position": [660, -100]
},
{
"parameters": {"respondWith": "noData", "options": {"responseCode": 401}},
"id": "5a500000-0000-4000-8000-000000000005",
"name": "Responder 401",
"type": "n8n-nodes-base.respondToWebhook",
"typeVersion": 1.1,
"position": [660, 140]
},
{
"parameters": {"operation": "executeQuery", "query": "UPDATE aprobaciones SET decision = $2, persona = (SELECT persona FROM personal WHERE usuario_slack = $3), decidida_en = now() WHERE id = $1 AND decision IS NULL RETURNING id, url_reanudar, persona;", "options": {"queryReplacement": "={{ [$('Verificar firma').first().json.aprobacion, $('Verificar firma').first().json.decision, $('Verificar firma').first().json.usuario_slack] }}"}},
"id": "5a500000-0000-4000-8000-000000000006",
"name": "Registrar decisión",
"type": "n8n-nodes-base.postgres",
"typeVersion": 2.6,
"position": [880, -100],
"alwaysOutputData": true,
"credentials": {"postgres": {"id": "0000000000000042", "name": "Postgres TL"}}
},
{
"parameters": {"conditions": {"options": {"caseSensitive": true, "leftValue": "", "typeValidation": "loose", "version": 2}, "conditions": [{"id": "c5000002", "leftValue": "={{ $json.url_reanudar }}", "rightValue": "", "operator": {"type": "string", "operation": "exists", "singleValue": true}}], "combinator": "and"}, "options": {}},
"id": "5a500000-0000-4000-8000-000000000007",
"name": "¿Primera decisión?",
"type": "n8n-nodes-base.if",
"typeVersion": 2.2,
"position": [1100, -100]
},
{
"parameters": {"method": "GET", "url": "={{ $json.url_reanudar + '?decision=' + $('Verificar firma').first().json.decision + '&persona=' + encodeURIComponent($json.persona) }}", "options": {"timeout": 10000}},
"id": "5a500000-0000-4000-8000-000000000008",
"name": "Reanudar aprobación",
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.2,
"position": [1320, -200]
},
{
"parameters": {"method": "POST", "url": "={{ $('Verificar firma').first().json.response_url }}", "sendBody": true, "specifyBody": "json", "jsonBody": "={{ JSON.stringify({ replace_original: true, text: 'Decisión: ' + $('Verificar firma').first().json.decision + ' por ' + $('Registrar decisión').first().json.persona + ' a las ' + $now.setZone('America/Mexico_City').toFormat('HH:mm') }) }}", "options": {}},
"id": "5a500000-0000-4000-8000-000000000009",
"name": "Actualizar mensaje",
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.2,
"position": [1540, -200]
},
{
"parameters": {"method": "POST", "url": "={{ $('Verificar firma').first().json.response_url }}", "sendBody": true, "specifyBody": "json", "jsonBody": "={{ JSON.stringify({ response_type: 'ephemeral', replace_original: false, text: 'Otra persona ya decidió este borrador; no se aplicó su acción.' }) }}", "options": {}},
"id": "5a500000-0000-4000-8000-000000000010",
"name": "Avisar ya decidido",
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.2,
"position": [1320, 40]
}
],
"connections": {
"Acción de Slack": {"main": [[{"node": "Verificar firma", "type": "main", "index": 0}]]},
"Verificar firma": {"main": [[{"node": "¿Firma válida?", "type": "main", "index": 0}]]},
"¿Firma válida?": {"main": [[{"node": "Responder 200", "type": "main", "index": 0}], [{"node": "Responder 401", "type": "main", "index": 0}]]},
"Responder 200": {"main": [[{"node": "Registrar decisión", "type": "main", "index": 0}]]},
"Registrar decisión": {"main": [[{"node": "¿Primera decisión?", "type": "main", "index": 0}]]},
"¿Primera decisión?": {"main": [[{"node": "Reanudar aprobación", "type": "main", "index": 0}], [{"node": "Avisar ya decidido", "type": "main", "index": 0}]]},
"Reanudar aprobación": {"main": [[{"node": "Actualizar mensaje", "type": "main", "index": 0}]]}
},
"settings": {"executionOrder": "v1", "timezone": "America/Mexico_City", "executionTimeout": 30, "saveDataSuccessExecution": "none", "errorWorkflow": "Xe7rTb4mKq9wPz2G"},
"pinData": {},
"meta": {"templateCredsSetupCompleted": true}
}
Tres detalles del JSON merecen una nota:
require('crypto')en el nodo Code necesita que el servidor de n8n permita ese módulo con la variableNODE_FUNCTION_ALLOW_BUILTIN, como ya se configuró el Día 38 para seudonimizar.alwaysOutputDataen el nodo de Postgres hace que el flujo continúe aunque la sentencia no devuelva filas; sin él, una segunda pulsación terminaría en silencio y la persona no sabría que su acción no se aplicó.saveDataSuccessExecutionennone: las ejecuciones correctas no guardan los datos de la petición en n8n. El registro de lo que importa (quién, qué, cuándo) ya está en Postgres; guardar además cada cuerpo de Slack sería una segunda copia sin finalidad.
El último bloque lee el JSON con la función nodos_desde_n8n, comprueba que todas las conexiones apuntan a nodos que existen, que el disparador recibe el cuerpo crudo, que no hay tokens de Slack ni direcciones de webhook escritos en el archivo y de dónde lee el secreto, y dibuja el workflow con las formas de la serie:
# Lectura del workflow TL Slack operaciones v1, comprobaciones de seguridad y dibujo en Python:
texto_wf = (carpeta / 'flujos' / 'n8n' / 'slack_operaciones_v1.json').read_text(encoding = 'utf-8')
workflow = json.loads(texto_wf)
FORMAS = {'webhook': ('disparador', '#1f9d55'), 'code': ('accion', '#2a78d6'), 'httpRequest': ('accion', '#2a78d6'),
'postgres': ('accion', '#2a78d6'), 'respondToWebhook': ('accion', '#2a78d6'),
'if': ('decision', '#eb6834'), 'wait': ('humano', '#8a8a8a')}
def nodos_desde_n8n(wf):
nodos = [{'id': n['name'], 'tipo': n['type'].split('.')[-1],
'x': n['position'][0] / 100, 'y': -n['position'][1] / 100} for n in wf['nodes']]
aristas = [(o, d['node'], tipo, i) for o, s in wf['connections'].items()
for tipo, lista in s.items() for i, destinos in enumerate(lista) for d in destinos]
return nodos, aristas
def dibujar_flujo(nodos, aristas, titulo):
fig, ax = plt.subplots(figsize = (15, 5.0))
pos = {n['id']: (n['x'], n['y']) for n in nodos}
for n in nodos:
forma, color = FORMAS[n['tipo']]
x, y = n['x'], n['y']
if forma == 'decision':
ax.add_patch(Polygon([(x - 0.95, y), (x, y + 0.55), (x + 0.95, y), (x, y - 0.55)], facecolor = color,
edgecolor = 'white', linewidth = 2))
else:
estilo = 'round,pad=0.05,rounding_size=0.35' if forma == 'disparador' else 'round,pad=0.05'
ax.add_patch(FancyBboxPatch((x - 0.85, y - 0.4), 1.7, 0.8, boxstyle = estilo,
facecolor = color, edgecolor = 'white', linewidth = 2))
ax.text(x, y + 0.1, textwrap.fill(n['id'], 13), ha = 'center', va = 'center', color = 'white', fontsize = 9)
ax.text(x, y - 0.27, n['tipo'], ha = 'center', va = 'center', color = 'white', fontsize = 7)
for o, d, tipo, salida in aristas:
(x1, y1), (x2, y2) = pos[o], pos[d]
ax.add_patch(FancyArrowPatch((x1 + 0.9, y1), (x2 - 0.9, y2), arrowstyle = '-|>', mutation_scale = 13,
color = '#52514e', linewidth = 1.4))
if pos[o] and FORMAS[next(n['tipo'] for n in nodos if n['id'] == o)][0] == 'decision':
ax.text((x1 + x2) / 2 - 0.35, (y1 + y2) / 2, 'sí' if salida == 0 else 'no', fontsize = 10,
color = '#52514e', ha = 'right')
ax.set_xlim(-1, max(p[0] for p in pos.values()) + 1)
ax.set_ylim(-2.0, 2.6)
ax.set_aspect('equal')
ax.axis('off')
ax.set_title(titulo, fontsize = 14)
return ax
nodos, aristas = nodos_desde_n8n(workflow)
nombres = {n['id'] for n in nodos}
print('Nodos:', len(nodos), '| conexiones:', len(aristas))
print('Tipos:', pd.Series([n['tipo'] for n in nodos]).value_counts().to_dict())
print('Destinos inexistentes:', [d for _, d, _, _ in aristas if d not in nombres] or 'ninguno')
disparador = workflow['nodes'][0]['parameters']
print('responseMode:', disparador['responseMode'], '| rawBody:', disparador['options']['rawBody'])
sospechosos = [p for p in ['xoxb-', 'xoxp-', 'hooks.slack.com/services/', 'Bearer '] if p in texto_wf]
print('Secretos o URL de webhook en el JSON:', sospechosos or 'ninguno')
print('Lee el secreto de firma de:', 'TL_SLACK_SIGNING_SECRET' if '$env.TL_SLACK_SIGNING_SECRET' in texto_wf else '¿?')
dibujar_flujo(nodos, aristas, f"{workflow['name']}, leído del JSON exportado")
plt.tight_layout()
plt.show()
Nodos: 10 | conexiones: 9
Tipos: {'httpRequest': 3, 'if': 2, 'respondToWebhook': 2, 'webhook': 1, 'code': 1, 'postgres': 1}
Destinos inexistentes: ninguno
responseMode: responseNode | rawBody: True
Secretos o URL de webhook en el JSON: ninguno
Lee el secreto de firma de: TL_SLACK_SIGNING_SECRET

El workflow tiene $10$ nodos y $9$ conexiones, ninguna rota; el disparador recibe el cuerpo crudo y delega la respuesta; no hay secretos en el JSON, y el secreto de firma se lee de TL_SLACK_SIGNING_SECRET. En el dibujo se ven las dos bifurcaciones del día: la de la firma, que separa lo auténtico de lo demás, y la de la primera decisión, que separa a quien llegó primero de quien llegó tarde. Ninguna de las dos depende de que las personas se coordinen.
Consideraciones finales
El día empezó con $51$ avisos en el WhatsApp personal de Valeria y termina con cuatro canales con una función cada uno, un mensaje de aprobación de $5$ bloques que se decide en un minuto, una firma que rechaza tres de cada cuatro peticiones de prueba por el motivo correcto, un relevo que sube los pasteles del $88.6%$ al $94.5%$ en $30$ minutos y un canal de alertas que pasa de $360$ mensajes al mes a $92$. Todo dentro del plan gratuito, porque el plan Pro para cinco personas costaría casi todo el presupuesto del ecosistema.
Las cifras tienen límites que conviene decir. Los tiempos de respuesta por franja son supuestos con forma lognormal, no mediciones; el mes de alertas es simulado y sus incidentes, inventados; los límites de Block Kit proceden de fuentes secundarias coincidentes y no de la referencia oficial; y el workflow no se exportó desde una instancia de n8n. Ninguno de esos límites cambia las decisiones de diseño, que se apoyan en la documentación oficial de Slack: la firma, la tolerancia, el plazo de tres segundos y el límite de tasa.
Antes de convertir cualquier herramienta de mensajería en el centro de operaciones de un negocio, conviene hacerse algunas preguntas:
- ¿Cada canal responde a una sola pregunta, qué tengo que hacer con lo que veo aquí?
- ¿Dónde queda el registro de las decisiones cuando el historial del plan gratuito lo borre de la vista?
- ¿Qué pasa si alguien pulsa un botón con una petición inventada, repetida o de hace diez minutos?
- ¿Cuántos avisos al día recibe su canal de alertas, y cuántos de ellos lee de verdad una persona?
- ¿Quién del equipo no está en la herramienta, y cómo le llega lo que necesita?
Un gran libro que habla de esto es Site Reliability Engineering: How Google Runs Production Systems (o Ingeniería de confiabilidad de sitios), editado por Betsy Beyer, Chris Jones, Jennifer Petoff y Niall Richard Murphy, cuyo capítulo sobre el monitoreo de sistemas distribuidos explica por qué un aviso que no exige una acción inmediata de una persona no debería interrumpirla, y cómo se separan los avisos de los informes. Está escrito para servicios con millones de usuarios, pero su criterio para decidir qué merece despertar a alguien se aplica sin cambios a una panadería con seis personas y cuatro canales.
Un centro de operaciones no es el lugar donde llegan todos los avisos: es el lugar donde solo llegan los que alguien tiene que atender. Trigo Lento ya lo tiene, con canales por función, botones que solo obedecen a peticiones firmadas, un libro de actas fuera de Slack y una tableta en el obrador para quien no quiere otra aplicación.
Conclusiones clave
Canales
- Los canales se organizan por función, no por herramienta ni por persona:
#pedidos-del-dia,#aprobaciones,#alertasy#mayoristas, con $876$ mensajes internos al mes. - Antes de publicar, cada evento pasa por una lista blanca de campos y por el horario laboral: nada de teléfonos ni textos de clientes, y lo no crítico nocturno espera al resumen de las 7:00.
- El plan gratuito ($90$ días de historial y $10$ integraciones) es el único que cabe: Pro para cinco personas costaría $43.75$ USD al mes; por eso el registro de aprobaciones vive en SQLite y Postgres.
Block Kit y webhooks
- Un webhook entrante es una dirección secreta por canal que acepta JSON con
textobligatorio; respondeok, y ante un exceso de ritmo, 429 conRetry-After, que se respeta sin inventar esperas. - El mensaje de aprobación usa $5$ bloques de $50$ (
section,context,divideryactions) y $3$ botones con el identificador del borrador como valor, nunca la dirección de reanudación. - Dibujar la maqueta y comprobar los límites a partir del propio JSON mantiene la documentación y el mensaje siempre de acuerdo.
Aprobaciones y alertas
- Cada pulsación se verifica con $\mathrm{HMAC}_{\mathrm{SHA256}}$ sobre
v0:marca:cuerpo crudo, con tolerancia de $5$ minutos y comparación de tiempo constante; la respuesta 200 sale antes de $3$ segundos. - Con tiempos lognormales por franja, el $69.7%$ de las aprobaciones se resuelve en $15$ minutos; el relevo de Don Ernesto a los $20$ minutos sube los pasteles del $88.6%$ al $94.5%$ en $30$, y una sentencia atómica garantiza que decide el primero.
- Severidades, sesiones de $10$ minutos y un resumen diario reducen un mes de $360$ avisos a $92$, con un máximo de $6$ por día; la fracción de errores que abre aviso es $e^{-\lambda \Delta}$.
Mañana, en el Día 51: Caso de otro negocio IV: recordatorios de citas por WhatsApp en la clínica dental. Lo construido para Trigo Lento en el módulo 5 (plantillas, ventana de 24 horas, consentimiento y la Cloud API desde n8n) viaja a la Clínica Dental Sonrisa Norte, donde cada recordatorio toca datos de salud y una cita perdida cuesta más que un pan sin recoger.
Referencias
- Slack (2026). Sending messages using incoming webhooks. Slack Developer Docs. https://docs.slack.dev/messaging/sending-messages-using-incoming-webhooks (revisado en octubre de 2026).
- Slack (2026). Block Kit. Slack Developer Docs. https://docs.slack.dev/block-kit (revisado en octubre de 2026).
- Slack (2026). Handling user interaction in your Slack apps. Slack Developer Docs. https://docs.slack.dev/interactivity/handling-user-interaction (revisado en octubre de 2026).
- Slack (2026). Verifying requests from Slack. Slack Developer Docs. https://docs.slack.dev/authentication/verifying-requests-from-slack (revisado en octubre de 2026).
- Slack (2026). Rate limits. Slack Developer Docs. https://docs.slack.dev/apis/web-api/rate-limits (revisado en octubre de 2026).
- Slack (2026). Pricing. https://slack.com/pricing (revisado en octubre de 2026).
- n8n (2026). Slack node: human in the loop approvals. Documentación de n8n. https://docs.n8n.io/integrations/builtin/app-nodes/n8n-nodes-base.slack/ (revisado en octubre de 2026).
- Krawczyk, H., Bellare, M. y Canetti, R. (1997). HMAC: Keyed-Hashing for Message Authentication. RFC 2104. IETF. https://www.rfc-editor.org/rfc/rfc2104
- Beyer, B., Jones, C., Petoff, J. y Murphy, N. R. (Eds.) (2016). Site Reliability Engineering: How Google Runs Production Systems. O’Reilly Media. (Capítulo 6, “Monitoring Distributed Systems”.)
- Little, J. D. C. (1961). A Proof for the Queuing Formula: L = λW. Operations Research, 9(3), 383-387.

