Casi todos los demos de agentes de IA terminan en el mismo punto: “el modelo llamó a la herramienta y funcionó”. Aplausos. Pero en un negocio real hay herramientas que no quieres que un modelo ejecute solo: pagar una factura, emitir un reembolso, comprar inventario.
Ahí entra el patrón human-in-the-loop (humano en el circuito): el agente hace todo el trabajo de preparación, pero se detiene antes de la acción costosa y espera a que una persona la apruebe.
Vamos a verlo con un caso práctico publicado por un Google Developer Expert en dev.to: un asistente de despacho para una empresa de servicio técnico, escrito completamente en Dart.
El problema: un agente que puede gastar dinero
El asistente responde preguntas como “¿en qué estado está la orden WO-1043?” o “pide una resistencia de 3 kW para la WO-1044”. Tiene tres herramientas:
| Herramienta | Qué hace | ¿Corre sola? |
|---|---|---|
lookup_work_order | Consulta la orden: sitio, equipo, falla, técnico | ✅ Sí |
schedule_visit | Agenda una visita técnica | ✅ Sí |
order_part | Compra un repuesto | ❌ Espera aprobación |
Las dos primeras son de bajo riesgo. La tercera cuesta dinero, así que necesita una firma humana.
La arquitectura: el agente solo conoce el negocio a través de MCP
El sistema tiene cuatro piezas:
- El back office como servidor MCP. Las órdenes de trabajo se exponen con el Model Context Protocol. Cualquier cliente MCP puede usarlas: este agente, un IDE u otro agente.
- El agente como un flujo de Genkit que se conecta a ese servidor MCP y obtiene las herramientas de ahí. Nunca importa el código del back office directamente.
- Una compuerta de aprobación (middleware
toolApproval) delante de cada herramienta que cuesta dinero. - Una API HTTP con un solo endpoint
POST /dispatchAgent.
¿Por qué usar MCP si todo vive en el mismo proceso? Porque la frontera es el punto. El día que las órdenes migren a un sistema real (un ERP, una base de datos externa), solo cambia el servidor MCP. El agente, su prompt y sus tests quedan intactos.
Un detalle sutil: la descripción de la herramienta order_part dice explícitamente “lo cual cuesta dinero”. El modelo también lee esa descripción, y eso influye en cómo la usa.
Un solo esquema para tres entornos
La respuesta del agente se define una sola vez como un tipo con tres campos: la respuesta en texto, las órdenes consultadas y un approvalId opcional que aparece cuando el agente se detuvo esperando permiso.
Ese mismo tipo sirve como:
- esquema de salida estructurada del modelo,
- respuesta HTTP del servidor,
- tipo que deserializa el cliente Flutter (web, iOS y macOS).
Cambias un campo, regeneras, y el compilador te avisa en cada lugar donde importa. Es el argumento a favor del full stack en un solo lenguaje, resumido en un archivo. Si trabajas con TypeScript, la idea es idéntica a compartir tipos con Zod entre tu backend Node.js y tu frontend React.
La compuerta: interrumpir, guardar, reanudar
El middleware recibe una lista de herramientas que pueden correr sin preguntar. Todo lo demás interrumpe la generación.
El flujo completo es así:
- El usuario pide un repuesto.
- El modelo decide llamar a
order_part. - El middleware detiene la ejecución. La respuesta vuelve con
finishReason = interrupted. - El servidor guarda la conversación y la llamada pendiente bajo un
approvalIdy responde de inmediato:
{
"answer": "Pedir \"Resistencia 3 kW\" para WO-1044 necesita tu aprobación.",
"orderIds": ["WO-1044"],
"approvalId": "approval-1"
}
- La app muestra los botones Aprobar y Rechazar.
- Si apruebas, la llamada guardada se reanuda: la herramienta corre y el modelo responde con el resultado real.
- Si rechazas, la herramienta nunca se ejecuta. El modelo recibe “Rechazado por el despachador. No se pidió nada” y se lo explica al usuario con sus propias palabras.
Un detalle de diseño que vale oro: cada approvalId se responde una sola vez. Se borra del mapa de pendientes al primer uso, así un doble clic no compra dos resistencias.
El bug: aprobar y… “Tool order_part not found”
Aquí la historia se pone interesante. Las consultas funcionaban, las visitas se agendaban, el aviso de aprobación aparecía. Pero al aprobar, la reanudación fallaba con Tool order_part not found.
La causa, en Genkit Dart 1.0.1:
- Las herramientas MCP se registran con nombre con espacio de nombres:
work-orders/order_part. - Al modelo se le muestra el nombre corto,
order_part, y lo devuelve así. - El ciclo normal resuelve el nombre corto con un mapa auxiliar, así que lo encuentra.
- El camino de reanudación no usaba ese mapa, buscaba
order_parten el registro global y no lo encontraba.
Conclusión: toda herramienta MCP funcionaba hasta que le ponías una aprobación delante. El autor lo reprodujo con dos tests, reportó el bug y abrió el PR con la corrección en el repositorio de Genkit Dart. Mientras se publica, la solución temporal es una herramienta local con el nombre corto que reenvía la llamada a la herramienta MCP.
La lección general: MCP combinado con interrupciones es terreno nuevo. Si construyes algo así, prueba el camino de reanudación, no solo el camino feliz.
Probar un agente sin API key
Los tests recorren el ciclo completo con un modelo falso: el modelo falso pide una herramienta MCP, la herramienta responde por HTTP real desde el servidor local y el flujo devuelve la respuesta estructurada.
Se prueban cuatro escenarios: pedido retenido, aprobado, rechazado y respondido dos veces. Resultado: 14 tests, cero llamadas a Gemini, sin API key y sin red externa.
Esto es clave: lo único que se simula es la parte cara y no determinista (el modelo). Todo lo demás es real, por eso puedes refactorizar la lógica de aprobación sin miedo.
Despliegue: un binario de 21 MB
Dart compila el servidor a un ejecutable nativo, así que la imagen Docker final parte de scratch (vacía) y no necesita el SDK. El resultado pesa unos 21 MB e incluye el agente, el servidor MCP y el router HTTP.
Se despliega en Cloud Run con un comando, la API key sale de Secret Manager y el servicio es privado (--no-allow-unauthenticated).
Un tip que te ahorra una tarde: no nombres tu health check /healthz en Cloud Run. El front end de Google reserva ciertas rutas terminadas en z y nunca las reenvía a tu contenedor. Usa /health.
Lo que te llevas para tus propios agentes
- La aprobación va en el middleware, no en el prompt. Un prompt que dice “siempre pregunta antes” puede ser convencido de lo contrario. Una interrupción en el código, no.
- Los IDs de aprobación se responden una sola vez. Si no, un reintento o un doble clic ejecuta dos veces una acción pagada.
- Separa el negocio del agente con MCP. Hoy es un mock, mañana es tu sistema real, y el agente no se entera.
- Simula solo el modelo en los tests. Así pruebas la lógica real sin gastar tokens.
- Las aprobaciones en memoria sirven para un demo. En producción guárdalas en una base de datos para que sobrevivan reinicios y puedas escalar a varias instancias.
Este patrón no es exclusivo de Dart: lo puedes implementar en Node.js con el SDK de MCP o en Python con cualquier framework de agentes. Lo que importa es el principio: el agente propone, la persona dispone.
Basado en el artículo original de un Google Developer Expert publicado en dev.to. Adaptado y ampliado para la comunidad DojoFullStack.