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:

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:

  1. Crea un registro pending_action en la BD con el tool name + argumentos
  2. Postea un mensaje en Discord: ”⏸ Quiero ejecutar publish_linkedin_post con: …”
  3. Agrega reacciones ✅ y ❌ al mensaje
  4. 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:

NO gatees:

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.


← todos los posts