Approval gates: el ingrediente que separa demo de producción
Por qué tu bot AI necesita un humano que apruebe acciones públicas — y cómo implementarlo bien sin matar la productividad.
Los demos de bots con tool-use son hermosos. “Mira, el bot publicó en LinkedIn solo”. Hasta que un día el bot:
- Anuncia un descuento que no existe
- Manda el mismo email tres veces porque malinterpretó tu instrucción
- Sube un PR con un typo en producción
- Inventa una propuesta con precios que no cobras
Llevo ~3 meses corriendo 11 agentes con tool-use en producción y la única cosa que separa “juguete divertido” de “sistema en el que confío” es esto: approval gates.
La regla
Toda acción con efecto público o irreversible requiere ✅ explícito de un humano específico antes de ejecutarse.
Tan simple, tan poderoso.
Implementación
Mi sistema mapea tool names a un set de “requiere aprobación”:
TOOLS_REQUIRING_APPROVAL = {
"restart_service",
"write_file",
"delete_file",
"git_commit_and_push",
"create_github_issue",
"create_github_comment",
"create_linear_issue",
"publish_linkedin_post",
"publish_bluesky_post",
"send_slack_message",
"send_whatsapp_message",
}
def needs_approval(tool_name: str) -> bool:
return tool_name in TOOLS_REQUIRING_APPROVAL
Cuando el agente decide llamar una de estas tools, no la ejecuta. En cambio:
- Crea un registro
pending_actionen la BD con el tool name + argumentos - Postea un mensaje en Discord: ”⏸ Quiero ejecutar
publish_linkedin_postcon: …” - Agrega reacciones ✅ y ❌ al mensaje
- Espera
Si reacciono con ✅, otro handler ejecuta la tool de verdad. Con ❌, se marca como rejected.
El bug que casi me cuesta caro: validar QUIÉN aprueba
Mi primera versión solo verificaba que el reactor fuera un humano (no el bot):
if user.bot:
return # ignore bot's own reactions
Pero cualquier humano podía aprobar. Si invitaba a alguien al servidor de Discord, esa persona podía aprobar restart_service, publish_linkedin_post, lo que fuera. Privilege escalation total.
La corrección, agregando whitelist:
authorized = set(
os.environ.get("APPROVAL_AUTHORIZED_USER_IDS", "").split(",")
)
if not authorized:
# fail-closed: si no hay lista, NADIE puede aprobar
return
if str(user.id) not in authorized:
await msg.reply(f"⛔ {user.mention} no autorizado")
return
Fail-closed > fail-open siempre. Si el archivo .env se borra accidentalmente, prefiero que nadie pueda aprobar (un downtime soportable) a que cualquiera pueda (un breach catastrófico).
El error de hallucination que me enseñó la lección
Le dije al agente creative: “publica este post en LinkedIn”. Me respondió: ”✅ Post publicado: https://linkedin.com/…”. Cuando fui a verificar, el link era 404. El agente había alucinado el éxito.
Lo que pasó: el agente llamó linkedin_health_check (que confirma que la integración está conectada), no publish_linkedin_post. Vio “ok” en el resultado de health_check y asumió que era éxito de publicación.
Tres fixes simultáneos:
1. Gate la tool real
publish_linkedin_post entró al set de TOOLS_REQUIRING_APPROVAL. Antes no estaba — error mío de configuración.
2. Anti-hallucination en el system prompt
⚠️ REGLA CRÍTICA — NO HALUCINAR RESULTADOS:
- NUNCA digas "Post publicado" si no ejecutaste publish_linkedin_post
y recibiste un resultado con `ok: true`.
- linkedin_health_check NO publica — solo verifica conexión.
- Si llamas publish_linkedin_post y el sistema responde con
un mensaje de aprobación pendiente, tu trabajo TERMINA ahí.
Espera a que Lalu apruebe. No inventes URLs.
3. Defensa en profundidad: validar el output
En el código, después de cualquier publish:
if not result.get("ok"):
return {"error": "publish failed", "details": result}
# Solo entonces persistir y reportar
Si el modelo intenta llamar otra tool en lugar de la real, el resultado no tiene ok: true y la lógica del bot lo rechaza.
Lo que NO quieres gatear (la trampa)
Si gateaste TODO, el bot se vuelve inútil. Cada query del usuario es un derecho de veto humano.
Mi regla:
Gatea solo lo que sale del sistema o es irreversible:
- Publicaciones públicas (LinkedIn, Twitter, Bluesky)
- Mensajes salientes (WhatsApp, Slack, email)
- Cambios destructivos al filesystem
- Operaciones de git con push
- Reinicio de servicios
NO gatees:
- Lectura (search, list, get)
- Generación de drafts en memoria
- Cálculos y consultas a la BD interna
- Memoria persistente (memory_save, memory_recall)
- PDFs locales que tú revisas antes de mandar
Esto deja el 90% de operaciones fluidas y solo te interrumpe cuando importa.
Cómo lo veo en Discord
Cuando un agente llama una tool gateada, en Discord aparece:
⏸ creative quiere ejecutar
publish_linkedin_post:text: "Somos 11 agentes AI. Así trabajamos para Lalu..."Reacciona ✅ para aprobar o ❌ para cancelar. expira en 1 hora
Reacciono con ✅ desde el celular en 2 segundos. Si está mal, ❌. Si me ausento, expira y se queda como pending_actions.status = 'expired'.
El resultado
En 3 meses, el sistema ha hecho ~200 acciones gateadas. Cero ejecutó algo que yo no aprobé. He rechazado ~15% de los drafts (texto que no me convencía, momento equivocado, decir cosas que no pensaba). Sin gates, esas 30 acciones habrían pasado.
El delta de productividad es mínimo — me toma 5 segundos extra por acción pública. El delta de tranquilidad es enorme.
Una nota sobre transparencia
A los clientes les digo abiertamente: “Tengo bots automatizando mi negocio, pero cada cosa que sale a ti pasa por mis ojos.” Lejos de espantarles, les da más confianza. Saben que no estoy ahorrándome el costo del humano — estoy ampliándolo.
Si quieres ver el sistema completo (incluyendo los approval gates en acción), abre oficina.lalu.dev. Los agentes están trabajando en este momento — el código que los mueve es exactamente este.