Agentes de IA en el navegador: por qué fallan y cómo arreglarlo
Tenía un agente que hacía una cosa aburrida y la hacía bien: leer una cola, crear el recurso por API, comprobar la respuesta, seguir. Si algo fallaba, lo repetía. Lo dejé corriendo toda una tarde sin mirarlo.
Luego apunté ese mismo agente al navegador, porque el proveedor de turno no tenía API. Misma tarea, mismo bucle, misma cabeza: así se montan hoy casi todos los agentes de IA en el navegador. Y ahí aparece el clásico. El clic de "Confirmar" parece no responder, el bucle reintenta, y al otro lado quedan dos pedidos idénticos.
No fue un bug del modelo. Fue el bucle haciendo exactamente lo que le pedí: reintentar. Hereda una política diseñada para un mundo donde reintentar es gratis, y el navegador es justo el sitio donde deja de serlo.
Esto no es un tutorial. Si quieres montarlo y verlo funcionando, el cómo está en automatizar la usabilidad con agentes de IA. Aquí va lo otro: por qué se rompe cuando lo pones a trabajar de verdad. Se rompe siempre por los mismos tres sitios —las esperas, el estado y las acciones que no se pueden deshacer— y ninguno de los tres es culpa del modelo.
Los agentes de IA en el navegador heredan un bucle que no es suyo
Un agente es un bucle: observa, decide, actúa, vuelve a observar. Lo conté con detalle en qué es el agentic loop, y todo lo que envuelve al modelo —tools, memoria, política de errores— es lo que llamamos harness.
Ese harness lleva dentro una suposición que casi nadie hace explícita: si una acción falla, se puede volver a intentar.
Con una API la suposición se sostiene. El POST devolvió timeout, no sabes si llegó, lo repites. Si la API es decente tienes una clave de idempotencia. Si no, tienes un staging donde da igual. El coste de un reintento es latencia.
En el navegador se cae. La acción ya salió de tu sistema: un pedido confirmado, un correo enviado, un registro borrado, una publicación en una cuenta real. Y tu bucle no recibe ningún código de estado: recibe una página repintada. Saber si aquello se completó es un paso aparte, que tienes que pedir tú. Casi nadie lo pide.
Y aquí está el detalle que provoca el desastre: nadie cambia el bucle al cambiar de herramienta. Sustituyes http_request por browser_click en la lista de tools y sigues con la misma política de reintentos y la misma tolerancia a errores.
Fallo 1: las esperas. Clicable no significa listo
Playwright, antes de actuar, comprueba que el elemento sea accionable. Son los actionability checks, y la documentación oficial los define así:
- Visible: tiene un bounding box no vacío y no tiene
visibility: hidden. - Stable: ha mantenido el mismo bounding box durante al menos dos frames de animación consecutivos.
- Receives Events: es el hit target del evento de puntero en el punto de la acción.
- Enabled: no está deshabilitado.
Lee esas cuatro definiciones otra vez. Son geometría y DOM. Ninguna dice nada sobre si tu aplicación tiene sentido.
Un botón "Confirmar pedido" puede estar visible, quieto durante dos frames, habilitado y ser el hit target mientras el precio final todavía se recalcula en el backend. Pasa los cuatro checks. El clic es prematuro. Playwright no ha mentido: nunca prometió esperar a que la app estuviera lista, solo a que el elemento fuera clicable.
Hay más. No todas las acciones piden lo mismo:
| Acción | Comprobaciones |
|---|---|
click(), dblclick(), check(), uncheck(), tap() |
Visible, Stable, Receives Events, Enabled |
fill(), clear() |
Visible, Enabled, Editable |
selectOption() |
Visible, Enabled |
hover(), dragTo() |
Visible, Stable, Receives Events |
press(), pressSequentially(), focus(), blur(), dispatchEvent(), setInputFiles() |
Ninguna |
Fíjate en dos cosas. fill() no espera a Stable: comprueba que el campo sea visible, esté habilitado y no sea de solo lectura, pero puedes escribir en un input que se está moviendo. Y hay un grupo entero de acciones sin ninguna comprobación, incluida dispatchEvent().
Ese grupo importa porque es la salida de emergencia. Cuando el clic normal "no funciona", el camino que aparece es disparar el evento a mano. En código de Playwright, locator.dispatchEvent('click'). Desde MCP no existe una tool equivalente: lo que hay es browser_evaluate, que ejecuta tu JavaScript sobre el elemento, y browser_run_code_unsafe, que la propia documentación describe como equivalente a ejecución remota de código.
Tres caminos distintos y el mismo efecto: ninguno pasa por los actionability checks.
El agente cree que ha encontrado una solución ingeniosa. Lo que ha hecho es quitar las comprobaciones que le impedían hacer clic demasiado pronto.
La corrección no es esperar más. Es esperar a otra cosa: a la condición de negocio, no al elemento.
// Espera al elemento. Pasa los cuatro checks y hace clic.
await page.getByRole(39;button39;, { name: 39;Confirmar pedido39; }).click();
// Espera a que la aplicación esté lista, y entonces hace clic.
await expect(page.getByTestId(39;gastos-envio39;)).toHaveText(/\d/);
await expect(page.getByTestId(39;spinner-total39;)).toBeHidden();
await page.getByRole(39;button39;, { name: 39;Confirmar pedido39; }).click();
Ojo con el orden: toBeHidden() en primera posición pasaría antes de que el spinner llegue a aparecer. Solo es seguro después de una aserción positiva.
Con Playwright MCP el equivalente es browser_wait_for sobre un texto que solo existe cuando el backend ya respondió. No "Total" —que está en el DOM desde el primer render—, sino "Envío calculado" o el importe con su formato final.
Esperar por estado y no por píxeles es la misma disciplina que separa una suite de tests fiable de una que falla los martes. La trabajo a fondo en el curso de Testing en Angular, y se traslada tal cual a los agentes.
Fallo 2: el estado. El agente decide sobre una foto caducada
En el fallo anterior el elemento no estaba listo. Aquí el elemento está listo, pero el agente mira una foto de hace tres segundos. La carrera es la misma; la corrección, no.
Playwright MCP no le manda screenshots al modelo. Le manda accessibility snapshots: un árbol estructurado de elementos accesibles con refs para interactuar, del tipo e5.
Es la decisión correcta. Playwright documenta un coste aproximado de 200-400 tokens por snapshot frente a 3.000-5.000 de un screenshot para un modelo de visión. Barato, textual, determinista.
Pero la letra pequeña está en la misma página: los refs son estables dentro de un único snapshot —el mismo elemento mantiene el mismo ref hasta que la página cambia— y quedan invalidados cuando la página cambia.
Traducción: el snapshot es una fotografía. Y entre la fotografía y el clic hay una llamada a un LLM que tarda segundos.
En esos segundos tu SPA puede recibir un evento por WebSocket, reordenar una lista, cerrar un modal o repintar una tabla. Si el nodo desapareció, el ref deja de resolver y la tool falla: molesto, pero visible.
El caso feo es el otro. Si tu framework recicla el nodo en vez de recrearlo —listas sin key o sin trackBy, scroll virtual—, el e5 que en la foto era "Eliminar borrador" de la fila 3 sigue vivo y ahora es el de la fila 4. Resuelve, el clic sale, y borras lo que no era. El agente no actúa sobre la página: actúa sobre su recuerdo de la página.
Un locator de Playwright se resuelve en el instante de actuar. Un ref se resolvió antes de que el modelo empezara a pensar. Toda la diferencia está ahí.
Hay un segundo efecto del estado que casi nadie cuenta, y es el que más planes rompe.
No puedes paralelizar agentes de navegador como paralelizas agentes de API. Playwright MCP arranca por defecto con un perfil persistente —ahí viven las sesiones y las cookies— y la documentación es tajante: un perfil persistente solo lo puede usar una instancia de navegador a la vez, así que varios clientes MCP concurrentes que compartan el mismo workspace entrarán en conflicto.
Con la configuración por defecto, ese perfil es un singleton. Lanzar diez subagentes contra la misma cuenta no te da diez veces el rendimiento: te da un conflicto, o peor, diez agentes pisándose la sesión.
La propia documentación da dos salidas: arrancar cada cliente extra con --isolated, o apuntarlo a un --user-data-dir distinto. La segunda te conserva la sesión guardada. La primera arranca limpia y pierde todo su storage al cerrar el navegador, así que le inyectas el estado inicial con --storage-state.
Para un agente que reintenta, quédate con la primera. Pierdes comodidad y ganas algo más valioso: poder tirar el estado y repetir el intento desde cero.
Fallo 3: el bucle no distingue lo que se puede deshacer
Divide en dos columnas todo lo que hace tu agente.
Idempotente: navegar, leer, hacer scroll, sacar un snapshot, abrir una pestaña, leer la consola. Repetirlo cien veces no cambia el mundo. Como mucho gasta tokens.
Con efecto externo: enviar el formulario, confirmar el pago, borrar el registro, publicar el post, invitar al usuario. Repetirlo cambia el mundo, y a veces cambia el mundo de otra persona.
Para el harness las dos son idénticas. browser_click sobre "Volver" y browser_click sobre "Confirmar pedido" son la misma tool call con distinto ref. El modelo cree que sabe la diferencia; el bucle, que es quien decide reintentar, no la sabe. Sobre los límites reales de un bucle en producción escribí en el loop del agente en producción.
La confirmación de que esto es un problema real no la pongo yo. La pone Anthropic.
Claude for Chrome, en beta para planes de pago, navega, hace clic y rellena formularios en tu navegador. El usuario puede pre-aprobar por sitio las acciones que le permite. Y aun así, el producto sigue preguntando antes de ciertas acciones irreversibles o potencialmente dañinas, como hacer una compra.
Si el reintento fuera seguro, ese gate no existiría. Nadie construye una interrupción humana específica alrededor de "comprar" por gusto: la construye porque comprar dos veces no se arregla razonando mejor. Es el mismo principio de aprobación previa del que hablé en computer use con Claude Code.
El navegador viene con tus credenciales dentro
La documentación de Playwright MCP tiene una frase que deberías pegar en la pared antes de darle un navegador a un agente:
Playwright MCP is not a security boundary.
(Playwright MCP no es una frontera de seguridad.)
No es un aviso legal. Es una descripción exacta de lo que estás montando. Ese navegador lleva las sesiones del usuario real, sus cookies, sus tokens y su banca abierta en otra pestaña. Y cualquier texto que la página muestre entra en el contexto del modelo.
Anthropic lo midió sobre su propia extensión. En el anuncio del piloto de Claude in Chrome, de agosto de 2025: 123 casos de prueba sobre 29 escenarios de ataque, 23,6% de éxito de la inyección de prompts sin mitigaciones y 11,2% con las defensas activadas en modo autónomo.
Fíjate en que el segundo número no es cero. Algo más de uno de cada diez intentos seguía pasando, en la configuración de quien fabrica el modelo y tiene todos los incentivos para que no pase.
Darle un navegador a un agente no es añadir una tool más al array. Es una decisión de arquitectura sobre qué credenciales pones al alcance de un bucle que, por diseño, insiste.
Qué hacer con esto hoy
-
Afirma el estado, no esperes al elemento. Antes de cada acción con efecto, exige una condición de negocio verificable: un texto que solo aparece cuando el backend respondió, un importe con formato final, un contador que cuadra. Si no sabes escribir esa aserción, tu agente tampoco sabe si la app está lista.
-
Clasifica tus acciones y cierra las pocas irreversibles con un gate. No pidas aprobación para todo: eso degenera en aprobar en automático en tres días. Pídela solo en la columna corta. En Claude Code se implementa con hooks, como lo monté en hooks como guardrails.
-
Usa una clave de idempotencia cuando la aplicación te la dé. Un identificador de operación en el formulario, un borrador con ID estable, un endpoint que acepta la misma referencia dos veces. Si controlas la app de destino, es media hora de trabajo y elimina la clase entera de fallo.
-
Trabaja con perfil aislado y
--storage-state. Estado desechable significa reintentos limpios. Estado persistente significa que el segundo intento arranca desde la basura del primero. -
Verifica después de actuar, no solo antes.
browser_network_requestsybrowser_console_messageste dicen si la petición salió y si la app se quejó. Actuar a ciegas y volver a intentar es exactamente cómo se duplican pedidos.
Diseñar así el bucle —qué se reintenta, qué se para, qué se verifica— es lo que separa un agente de demo de uno que dejas suelto, y es lo que trabajamos en el curso Construye con IA.
El bucle no va a distinguir por ti entre leer y comprar. Esa frontera la dibujas tú, hoy, antes de la próxima ejecución. Si quieres ver estos patrones aplicados sobre proyectos reales y con el código delante, en Dominicode Labs es donde los montamos.
Preguntas frecuentes
¿Por qué mi agente funciona bien contra APIs y falla en el navegador?
Porque el bucle está construido sobre la idea de que una acción fallida se puede repetir. Con una API eso suele ser cierto y barato. En el navegador, la acción ya produjo un efecto fuera de tu sistema —un pedido, un correo, un borrado— y confirmar si se completó exige un paso extra que el bucle no da solo. Cambiaste la herramienta, no la política de reintentos.
Si Playwright ya espera a que el elemento sea accionable, ¿por qué hace clic antes de tiempo?
Porque sus comprobaciones son geométricas y de DOM: visible, estable, hit target y habilitado. Estable significa que el bounding box no cambió durante dos frames de animación, no que los datos hayan cargado. Un botón puede pasar los cuatro checks mientras el backend todavía calcula. Clicable no es lo mismo que listo.
¿Cómo hago que un agente espere a que la aplicación esté lista de verdad?
Espera por una condición de negocio, no por un elemento: un texto que solo se renderiza cuando llegó la respuesta, un importe con su formato final, un estado que pasó de "calculando" a un valor. Con Playwright MCP, browser_wait_for sobre ese texto. Regla práctica: si el marcador que esperas ya está en el DOM en el primer render, no te sirve.
¿Puedo lanzar en paralelo varios agentes de IA en el navegador?
No con la configuración por defecto. Playwright MCP usa un perfil persistente y su documentación advierte de que solo lo puede usar una instancia de navegador a la vez, así que varios clientes MCP concurrentes en el mismo workspace entran en conflicto. La salida documentada es arrancar cada cliente extra con --isolated —con --storage-state si hace falta sesión inicial— o apuntarlo a un --user-data-dir distinto si quieres conservar la sesión. Y, normalmente, cuentas distintas.
¿Qué acciones de un agente de navegador deberían pedir aprobación humana?
Solo las que no se pueden deshacer: pagos, envíos definitivos, borrados, publicaciones e invitaciones. Navegar, leer o sacar snapshots no necesitan gate. Es la línea que sigue Claude for Chrome, que pre-aprueba por sitio y aun así vuelve a preguntar antes de acciones irreversibles como una compra. Si pides confirmación para todo, en tres días la darás en automático.
¿Es mejor accessibility snapshot o screenshot para un agente de navegador?
El snapshot, casi siempre. Playwright documenta un coste aproximado de 200-400 tokens por snapshot frente a 3.000-5.000 de un screenshot para un modelo de visión, y además da refs con los que interactuar. Su límite es otro: esos refs solo son estables dentro de ese snapshot y se invalidan cuando la página cambia. El screenshot sigue disponible como tool por defecto (browser_take_screenshot) para lo que el árbol de accesibilidad no expresa, como un canvas o un mapa. Lo que habilita --caps=vision no es la captura: es poder interactuar por coordenadas sobre ella.
Por Bezael Pérez — Developer senior con más de 15 años de experiencia y fundador de Dominicode.
