Las Aventuras de 🐺 villawolf y 🤖 chigu — Episodio 4: El Gate Invisible
Las Aventuras de 🐺 villawolf y 🤖 chigu — Episodio 4: El Gate Invisible
Mi smoke test pasó. chigu creó un invoice de 10 sats en Blink wallet, autónomo, en su primera ejecución end-to-end. Le faltaba un detalle: mi defensa anti-Freysa nunca se ejerció. La capa de Policy que escribí con tanto cuidado se saltó silenciosamente. El smoke verde mentía.
De dónde venía
En el episodio anterior conté cómo terminé escribiendo BlinkMakeInvoiceTool después de descubrir que Blink todavía no expone NWC en producción. Una API key con scope Read+Receive y dos llamadas GraphQL: una para crear invoice, otra para consultar su estado. 269 líneas. Smoke OK. Anti-Freysa validado server-side: si chigu fuera jailbreakeado y construyera un BOLT11 perfecto para pagar, Blink rechaza la mutation entera porque la API key no tiene Write.
Pero un agente no es una sola tool. Para que chigu sea agente y no un script con esteroides necesita 4 piezas separadas: un Run que orqueste el loop de razonamiento, un set de Tools que sepan hacer cosas, una Memory que se acuerde de lo que pasó, y una Policy que decida si una acción se ejecuta o no antes de tocar el mundo.
El paso 6 del build de F1 era cerrar las cuatro. Cinco sub-pasos seguidos. ~705 líneas nuevas (~975 totales con paso 5). Hoy las cerré.
Los 4 contratos en 705 líneas
Cada contrato es un Python Protocol pequeño que vive en chigu/core/contracts.py. La idea de fondo viene del diseño de capa de abstracción que dejé en notas hace una semana: lo que es estable (los 4 contratos) se separa de lo que cambia (los adapters: framework, wallet, modelo, hosting). Cuando el día de mañana migre de Blink a Alby Hub, o de Claude Agent SDK a LangGraph, escribo un nuevo adapter, el código de negocio no se entera.
Los adapters de F1 quedaron así:
| Contrato | Implementación F1 | Archivo |
|---|---|---|
Tool |
BlinkMakeInvoiceTool + BlinkLookupInvoiceTool |
tools/blink_invoice.py (paso 5) |
Policy |
CompositePolicy([WriteDeniedPolicy, HITLPolicy]) |
policy/composite.py |
Memory |
FilesystemMemory → JSONL en disco |
memory/filesystem.py |
Run |
ClaudeAgentSDKRun envolviendo query() del SDK |
run/claude_sdk.py |
Modelo para esta etapa: Claude Haiku 4.5. El SDK Python de Claude Agent (versión 0.1.80) corre arriba del CLI de claude-code en Node — eso agrega un costo fijo de ~23k tokens de system prompt por llamada, lo que pone cada query alrededor de $0.03-0.10 dependiendo del trabajo del agente. Pagable para F1 mientras el caso de uso es generar un invoice ocasional. Optimizable con sesiones largas (cache hit es 10× más barato) cuando aparezca el volumen.
Tomé 12 decisiones de diseño en el camino. Dos importan para esta historia. La primera: WriteDeniedPolicy consulta getattr(tool, "moves_sats", False) — el marcador es opt-in, no toca el Tool Protocol. Las dos tools de Blink no lo declaran (no mueven sats hacia afuera), así que pasan. Cuando aparezca pay_invoice en F3, esa Tool declara moves_sats = True y queda bloqueada por construcción. La segunda: la Policy retorna ALLOW, DENY o REQUIRE_HITL. El último caso es para tools que requieren confirmación humana; en F1 ninguna lo necesita pero la maquinaria queda lista.
El smoke verde que mentía
Compuse el smoke 6.5 con las cuatro piezas:
tools registradas: ['make_invoice', 'lookup_invoice']
memory tmp: /tmp/tmpcv1f0etg
policy: CompositePolicy con 2 sub-policies
>>> input: "Crea un Lightning invoice de 10 sats con memo 'chigu paso6 smoke run'."
El agente leyó el prompt, razonó por su cuenta que necesitaba la tool make_invoice, la llamó con los argumentos correctos, recibió el BOLT11 real de Blink (lnbc100n1p4q5zd5pp5dtsagw8twzvwqg8vnrksug76wxnynrhncw4m5zdzt2ypg4...) y me respondió en castellano con el invoice listo para copiar. La primera vez que chigu actuó como agente, no como pipeline.
El smoke imprimió:
[OK] tool_call events: 1
[OK] make_invoice ejecutado via SDK
[OK] output.text no vacio (355 chars)
Solo había un problema. Yo había escrito el assert para que verificara también que la Policy se hubiera ejercido — esperaba al menos un evento policy_gate en la memoria episódica antes del tool_call. Y ese assert reventó:
AssertionError: Expected al menos 1 policy_gate, got 0
Cero. La capa Policy que armé con tanto cuidado, donde supuestamente vivía la defensa redundante anti-Freysa, nunca se invocó. El agente movió la llamada a Blink sin pasar por mi gate. El smoke verde de “tool_call ejecutado” estaba diciéndome la verdad sobre la tool y mintiéndome sobre la arquitectura.
El bug: allowed_tools cortocircuita can_use_tool
El SDK de Claude Agent expone un callback can_use_tool que se invoca antes de cada ejecución de herramienta. Es el hook obvio donde meter Policy: el SDK pregunta “¿puedo correr esto?”, mi callback delega a policy.gate(...), retorna Allow o Deny, el SDK actúa en consecuencia. Perfecto.
Lo que yo no había leído con cuidado: el SDK también acepta allowed_tools=[...] como lista de tools pre-aprobadas. Si una tool está en esa lista, el SDK la ejecuta directo y no llama al callback. Es una optimización razonable: si tú ya dijiste “estas tools están bien”, no tiene sentido preguntar otra vez.
Yo, ingenuamente, había puesto las dos tools de chigu en allowed_tools pensando que era “la lista de tools que existen para el agente”. No era eso. Era una bypass de Policy. Mi callback estaba ahí, listo, esperando y nunca era llamado porque cada tool que el modelo intentaba usar ya estaba pre-aprobada.
El fix fue una línea: allowed_tools=[]. Dejar can_use_tool como única vía de aprobación. El callback ya estaba escrito para manejar todos los casos: si la tool está registrada en chigu, delegar a policy.gate; si no (un built-in del CLI, otra MCP, lo que sea), denegar por default. Allowlist mode estricto, pero ejercido desde Policy, no desde una lista a espaldas del gate.
Re-corrí el smoke. La memoria episódica esta vez mostró:
[2026-05-17T18:32:19] policy_gate: {'tool': 'make_invoice', 'outcome': 'allow', 'reason': None}
[2026-05-17T18:32:20] tool_call: {'tool': 'make_invoice', 'args': {'amount_sats': 10, ...}, 'ok': True}
El gate fue antes de la ejecución, como debe ser. Por menos de un segundo, pero antes. Auditoría completa.
Grabé un comentario inline arriba del allowed_tools=[] como recordatorio para el yo del futuro:
# IMPORTANTE: allowed_tools=[] a proposito. El SDK pre-aprueba# cualquier tool listada, lo que romperia 'Policy es no-opcional'.
La lección que vale más que el código
Una abstracción que typea, importa y compila no garantiza que se ejecute. La defensa-en-capas no sirve si una de las capas se saltea silenciosamente porque no leíste un parámetro del framework con cuidado. Y lo más importante: el código verde no es prueba de arquitectura correcta. Es prueba de que el output esperado salió. La arquitectura se verifica con asserts que prueban que las capas correctas se ejercieron, no solo que el resultado final llegó.
Si yo no hubiera escrito el assert len(gate_events) >= 1 por costumbre defensiva, hoy chigu estaría en producción de F1 con una defensa Policy completamente inútil, y yo confiado de tenerla. Probablemente la habría descubierto recién cuando intentara agregar la primera tool con moves_sats = True en F3, viendo que se ejecuta sin chistar.
El assert que verifica que el gate fue invocado es más valioso que las 220 líneas del Run. Una sin la otra es teatro.
Dónde está chigu mientras lees esto
Honestidad de la serie: este episodio cuenta el paso 6, pero el build corrió bastante más lejos mientras yo escribía. Para que no leas noticias viejas como si fueran nuevas, el estado real al día de hoy:
- chigu ya recibió su primer sat real. 10 sats pagados desde una wallet externa al invoice que generó él solo, detectados por polling, status PENDING → PAID en 189 segundos (3 m 9 s). Eso cerró el F1.
- La fase 2 está abierta. Construí un MCP propio para L402 — el esquema de pago HTTP 402 sobre Lightning que le deja a un agente pagar por recursos web: un servidor con tres tools (
probe,pay,fetch) y un payer intercambiable. 35/35 tests en verde. - El “dance” L402 completo está validado contra un servidor local: chigu pide un recurso, recibe el 402, paga, y recibe el contenido. Con eso decidí dónde va a vivir la wallet de gasto cuando el nodo Bitcoin propio termine de sincronizar.
Cada uno de esos saltos es un episodio que viene. No los amontono aqui: apretados pierden los números que los hacen valer.
Qué viene en el siguiente episodio
El próximo episodio desarma el paso 7, la prueba que importa y que cerró el F1: chigu genera un invoice, alguien paga externamente desde cualquier wallet LN, chigu detecta el pago vía polling y graba el evento. La primera vez que sats reales entran a la wallet del agente por su propia mano. Con la cocina por dentro: el patrón híbrido de tres fases (el LLM crea, Python espera, el LLM narra) y por qué la validación anti-jailbreak quedó como deuda para más adelante.
Va en el Ep 5. Si quieres ser de los primeros en pagarle a chigu, ya puedes: la dirección está abajo.
chigu@blink.sv · Nostr:
npub18rl9xeaxw0leee0easqu9cngrq0ny4zsmdsx4jhj5fkfmstgklhq2q5mqz\Si esto te es útil, un zap es la mejor señal. Si algo está mal, comenta, lo agradezco más que el zap.
Write a comment