Cargando
Miniatura del artículo

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:

python
# 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:

python
# 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.

CanalQué se publicaQuién lo leeSeveridadFrecuencia esperada
#pedidos-del-diaCada encargo nuevo (producto, cantidad, hora, cliente seudonimizado) y la lista de las 7:00Mostrador y panaderosInformativa494 al mes, unos 19 por día laboral
#aprobacionesBorradores que esperan un sí: correos, pasteles, publicaciones y respuestas a quejasValeria; el obrador a los 20 minutos para los pastelesAcción222 al mes, unas 9 por día
#alertasFallos de flujos, cuotas agotadas y avisos de gasto, ya agrupadosValeriaAlta o crítica104 al mes como objetivo, unas 4 por día
#mayoristasBrújula: confirmación diaria, cambios de cantidad y facturas semanalesValeria y mostradorInformativa56 al mes

La traza de los cinco eventos de ejemplo enseña las dos reglas generales en acción:

  1. El encargo de las 9:12 llega a #pedidos-del-dia sin teléfono ni texto. El evento traía telefono y texto porque así lo entrega el webhook de WhatsApp; la lista blanca deja solo cantidad, cliente, producto y tipo. El cliente aparece como cli_7f3a9c21e0, el seudónimo del Día 6, que basta para que el mostrador lo encuentre en la base.
  2. 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 #alertas a esa hora, porque esperar a la mañana podría significar flujos detenidos.
  3. 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.

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.

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).

ConceptoValorFuenteConsecuencia en Trigo Lento
Historial del plan gratuito90 díasPágina de precios de SlackEl registro de aprobaciones vive en SQLite y Postgres
Integraciones del plan gratuitoHasta 10Página de precios de SlackUna sola aplicación, TL Operaciones
Plan Pro8.75 USD por persona al mes (7.25 en pago anual)Página de precios de Slack43.75 USD para cinco personas: no cabe en el presupuesto
Ritmo de publicación1 mensaje por segundo por canal, con ráfagas cortasDocumentación de límites de tasaPausa de 1 segundo entre mensajes y agrupación de alertas
Exceso de ritmoHTTP 429 con Retry-After en segundosDocumentación de límites de tasaEsperar lo que diga la cabecera, nunca un tiempo fijo
Bloques por mensaje50 (100 en ventanas modales)Fuentes secundariasEl mensaje de aprobación usa 5
Confirmar una interacciónHTTP 200 en 3 segundosDocumentación de interactividadn8n responde antes de tocar la base de datos
response_urlHasta 5 usos en 30 minutosDocumentación de interactividadSe usa una vez para cambiar el mensaje tras decidir
Tolerancia de la firma5 minutosDocumentación de verificación de peticionesSe 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 campo text es obligatorio (sin él, Slack responde no_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 200 con el texto ok. 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_service o no_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étodo chat.postMessage de 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:

python
# 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 formato mrkdwn (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, un action_id que identifica la acción, un value que viaja con la pulsación y, opcionalmente, un estilo (primary en verde, danger en 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:

json
{
  "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.

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.

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:

python
# 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')]

Gráfica: Block Kit. Lectura del mensaje de Block Kit, comprobación de límites y maqueta dibujada con patches

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:

  1. Llega como formulario, con Content-Type: application/x-www-form-urlencoded y 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_id y value) y una response_url.
  2. 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.
  3. La response_url admite 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)OrigenAprobarEditarRechazar
Borrador de correo a mayorista (120)Gmail, Día 47Etiqueta el borrador como revisado; Valeria lo envía desde Gmail, porque el flujo no tiene permiso de envíoAbre el borrador en Gmail para corregirloDescarta el borrador y deja el correo en la bandeja de Valeria
Pastel personalizado (40)Agente de pedidos, Día 38Reanuda el Wait con aprobar: el borrador pasa a pedido y el agente confirma a la clientaReanuda con editar: Valeria corrige el borrador y el agente no contestaReanuda con rechazar: la clienta recibe un aviso para hablar con Valeria
Publicación en redes (36)Módulo 7Programa la publicaciónAbre el texto para corregirloArchiva el borrador
Respuesta a queja (26)Agente de pedidosEnvía la respuesta por WhatsApp dentro de la ventana de 24 horasValeria reescribe la respuestaValeria 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 prefijo v0 es 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:

python
# 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.

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.

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:

python
# 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

Gráfica: Cuánto tarda una aprobación. Simulación del tiempo hasta la aprobación por franja horaria y efecto del relevo

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:

  1. 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.
  2. 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.
  3. 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í.
  4. 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:

python
# 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

Gráfica: Fatiga de alertas. Agrupación de alertas por sesiones de 10 minutos y severidad en un mes simulado

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.

SeveridadAntes (un mensaje por error)DespuésQué pasó con ellas
Alta17061Agrupadas en sesiones de 10 minutos; las nocturnas pasan al resumen
Baja1890Ninguna interrumpe: todas van al resumen de las 7:00
Crítica11Se publica siempre, a cualquier hora
Resumen de las 7:00030Un mensaje al día con lo bajo y lo nocturno
Total36092Reducción del 74.4 %
Máximo en un día516El día 9, los 40 errores del webhook son 1 aviso
Días por encima de 1530Umbral tolerable fijado por Valeria

Tres lecturas de la gráfica y de la tabla:

  1. 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.
  2. 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.
  3. 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:

python
# 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:

  1. Acción de Slack (webhook): recibe el POST de Slack en la ruta slack-acciones, con la opción de cuerpo crudo activa y la respuesta delegada a un nodo posterior (responseMode en responseNode).
  2. Verificar firma (code): lee el secreto de $env.TL_SLACK_SIGNING_SECRET, calcula el HMAC sobre el cuerpo crudo, compara con crypto.timingSafeEqual, comprueba la tolerancia de $300$ segundos y, solo si todo cuadra, interpreta el payload.
  3. ¿Firma válida? (if): separa las peticiones auténticas de las demás.
  4. Responder 200 y Responder 401 (respondToWebhook): contestan a Slack antes de hacer nada más, dentro de los $3$ segundos.
  5. Registrar decisión (postgres): una sola sentencia UPDATE ... WHERE decision IS NULL RETURNING que registra la decisión, la persona y la hora solo si nadie ha decidido antes, y devuelve la dirección de reanudación.
  6. ¿Primera decisión? (if): si la sentencia devolvió una fila, sigue; si no, alguien se adelantó.
  7. 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:

json
{
  "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 variable NODE_FUNCTION_ALLOW_BUILTIN, como ya se configuró el Día 38 para seudonimizar.
  • alwaysOutputData en 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ó.
  • saveDataSuccessExecution en none: 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:

python
# 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

Gráfica: TL Slack operaciones v1. Lectura del workflow TL Slack operaciones v1, comprobaciones de seguridad y dibujo

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, #alertas y #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 text obligatorio; responde ok, y ante un exceso de ritmo, 429 con Retry-After, que se respeta sin inventar esperas.
  • El mensaje de aprobación usa $5$ bloques de $50$ (section, context, divider y actions) 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


  1. 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).
  2. Slack (2026). Block Kit. Slack Developer Docs. https://docs.slack.dev/block-kit (revisado en octubre de 2026).
  3. 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).
  4. Slack (2026). Verifying requests from Slack. Slack Developer Docs. https://docs.slack.dev/authentication/verifying-requests-from-slack (revisado en octubre de 2026).
  5. Slack (2026). Rate limits. Slack Developer Docs. https://docs.slack.dev/apis/web-api/rate-limits (revisado en octubre de 2026).
  6. Slack (2026). Pricing. https://slack.com/pricing (revisado en octubre de 2026).
  7. 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).
  8. Krawczyk, H., Bellare, M. y Canetti, R. (1997). HMAC: Keyed-Hashing for Message Authentication. RFC 2104. IETF. https://www.rfc-editor.org/rfc/rfc2104
  9. 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”.)
  10. Little, J. D. C. (1961). A Proof for the Queuing Formula: L = λW. Operations Research, 9(3), 383-387.
comments powered by Disqus