Stagehand v4: qué pasa de verdad cuando lo corrés
Instalamos Stagehand 4.0.0, lo corrimos contra una página real, medimos cada token que gastó, y lo rompimos a propósito. Esta nota arranca exactamente donde termina nuestro análisis de arquitectura de Stagehand v4: ya no "qué es", sino "qué pasa cuando escribís pnpm install y empezás a llamarlo". Cada número de acá abajo sale de un script que podés encontrar y volver a correr, no de la documentación.
Cómo lo verificamos. Stagehand 4.0.0, Ubuntu 24.04, Node 22, Chrome 151, probado en local y en Browserbase, contra 4geeks.com y otras páginas reales, en agosto de 2026. Donde un hallazgo contradice notas anteriores de esta misma investigación, lo aclaramos.
Instalación: más rápida de lo esperado, con un borde filoso
pnpm install @browserbasehq/stagehand zod baja 51 paquetes en unos 4.5 segundos y no descarga ningún navegador. Si venís de Playwright, esa es la primera sorpresa: v4 maneja el Chrome que ya tenés instalado, vía CHROME_PATH, no un navegador que administra por vos.
Lo único que te va a frenar en seco: pnpm init escribe devEngines.packageManager.version: "^11.21.0" en el package.json. corepack rechaza ese rango directamente:
Invalid package manager specification in package.json (pnpm@^11.21.0);
expected a semver versioncorepack quiere una versión exacta, y pnpm init no la da. npm install -g pnpm evita todo el problema.
Dos trampas más de configuración, antes de tocar código:
- Stagehand no lee tus variables de entorno, a pesar de que la documentación tiene una tabla de proveedores con una columna "Environment Variable" que parece indicar detección automática. Configurás
GOOGLE_GENERATIVE_AI_API_KEYy listo, pensás, pero te encontrás conModel inference requires a provider API key or a Browserbase session, un error que apunta a autenticación cuando el problema real es que nunca leyó la variable. - El registro en Browserbase pide verificación de número de teléfono. No una tarjeta, pero sí una barrera real que conviene mencionar antes de que alguien llegue ahí a mitad de un tutorial.
La documentación te manda a la página equivocada primero
Buscá la configuración de modelos de Stagehand y el primer resultado es docs.stagehand.dev/configuration/models, que sirve la página de v3: 13 proveedores listados. La página actual, en /v4/, lista 5. Quien sigue el link obvio termina con una lista de proveedores que ya no existe.
Esa misma superficie desactualizada es de donde sale el error más común. El propio ejemplo de Model Gateway en la documentación dice:
const stagehand = new Stagehand({ env: "BROWSERBASE", model: "openai/gpt-5" });En v4, un string a secas para model tira un ZodError: expected object, received string. La forma real es:
model: { modelName: "google/gemini-3-flash-preview", apiKey: "..." }Ese snippet no está mal, en rigor. Es Python válido (el SDK de Python sí acepta un string, con la clave como argumento aparte), pegado en una página de documentación de TypeScript.
extract() tiene el mismo problema, y es peor por cómo se lee el error. Todos los ejemplos en circulación, incluyendo la documentación oficial, escriben:
await stagehand.extract({ instruction: "…", schema: z.object({…}) }); // fallaEl error que tira nombra instruction: expected string, received object. Se lee como si tu instrucción estuviera mal escrita. No es así. La firma real es posicional:
await stagehand.extract("get the heading", z.object({ heading: z.string() }), { timeout: 30000 });Sin schema, extract() devuelve { extraction: string }, un string plano sin estructura. Vale la pena saberlo antes de asumir que falló en silencio.
Algunas costumbres de Playwright más chicas que también se rompen: page.url() ahora es async (código portado de Playwright imprime Promise { <pending> } en vez de una URL), page.on() solo acepta "console", y logLevel: "debug" no es una opción real; es logging: { level, format, onLog }.
Lo que realmente cuesta, por llamada
Cada llamada de inferencia envía el árbol de accesibilidad completo de la página al modelo. En la home de 4geeks.com, ese árbol pesa 67.347 caracteres, unos 21.000 tokens, antes de sumar tu instrucción. Ese número es la historia completa: el costo sigue el tamaño de la página, no lo que pediste.
| Método | Tokens de entrada | Tokens de salida | Tiempo | Llamadas al modelo |
|---|---|---|---|---|
observe() | 20.956 | 38–758 | 5–8 s | 1 |
act() por instrucción | 16.006 | 32 | 9.8 s | 1 |
act() replay (acción cacheada) | 0 | 0 | 0 s | 0 |
extract() | 16.419 | 104 | 13.3 s | 2 |
act() self-heal | 21.410 | 44 | 1.4 s | 1 |
La fila de extract() tiene un costo escondido que nada muestra por sí solo: hace dos llamadas al modelo, no una. La primera extrae según tu schema. La segunda revisa su propio trabajo contra un schema interno, { progress: string, completed: boolean }, decidiendo si el objetivo está "ahora cumplido". metadata.usage reporta ambas llamadas como si fueran una sola, así que un presupuesto armado sobre "una llamada por extract()" está mal desde la primera corrida. Una secuencia observe() + extract() son tres solicitudes, no dos, y eso importa más que nada en un plan gratuito que cuenta solicitudes.
La única configuración que mueve la aguja de verdad: locator
Acotar una llamada a una subrama en vez de entregar la página entera es la palanca más grande de toda la librería, y no aparece en la guía rápida.
| Variante | Tokens de entrada | Cambio |
|---|---|---|
| Página completa | 15.883–20.957 | base |
locator: page.locator("header") | 481–950 | −94% a −98% |
locator: page.locator("nav") | 471–941 | −94% a −98% |
ignoreLocators: [footer] | 12.482–15.746 | −1% a −40%, según la página |
Acotar hacia adentro le gana a excluir hacia afuera. ignoreLocators solo ayuda en proporción a qué tan grande sea la subrama excluida, algo que varía por página; locator da un recorte predecible y casi total en cada llamada.
Una trampa de la API: locator toma una instancia de Locator real, el objeto que devuelve page.locator(...). Pasar { selector: "header" } tira error. Esa forma de objeto no es el formato que espera.
Una contra a tener presente: locator cambia el caché del lado del servidor a DISABLED para esa llamada. El ahorro del 94% en tokens y un acierto de caché son mutuamente excluyentes en la misma solicitud; elegís uno por llamada, no los dos.
La trampa del caché: es un contador, no un puntaje de similitud
cache.threshold viene por defecto en 10, y es un contador de repeticiones: la instrucción idéntica tiene que correr contra la página idéntica diez veces antes de que algo salga del caché. Por eso { threshold: 0.8 } tira expected int, received number, no un fallo de similitud; el campo nunca fue pensado para aceptar una fracción. La mayoría, al leer "threshold", asume lo segundo.
Ponelo en 1 y un acierto cuesta 0 tokens y 0 milisegundos, confirmado con tokensSaved en la respuesta. Las entradas persisten entre sesiones y están asociadas a la cuenta, y el modelo queda deliberadamente fuera de la clave del caché, así que podés calentar un caché con un modelo barato y correr uno caro contra él gratis después. Todo esto es exclusivo de Browserbase: un navegador local acepta cache: true sin quejarse y cada resultado sigue volviendo como { "cache": { "status": "DISABLED" } }.
Un detalle de seguridad del caché fácil de pasar por alto. La sustitución %variable% mantiene los valores secretos fuera del prompt del modelo, esa es la protección anunciada. Pero con el caché del lado del servidor activado, los valores reales igual viajan al servicio de caché. Desactivá el caché para cualquier llamada que lleve credenciales.
model: { generate }: la salida que la documentación no menciona
model es un tipo unión. Una rama es la conocida { modelName, apiKey }, validada contra una lista fija de 147 nombres exactos de modelo en cinco proveedores (openai, anthropic, google, groq, cerebras). La otra rama es { generate }: le pasás a Stagehand una función propia, y la llama en vez de cualquier proveedor incorporado.
Esa función corre en Node, no dentro de la extensión, confirmado al loguear el process ID desde adentro. Tres consecuencias:
- Cualquier proveedor se vuelve usable: OpenRouter, Ollama, Bedrock, un modelo local, cualquier cosa alcanzable por HTTP. La lista de 147 nombres deja de ser una limitación.
- Podés loguear exactamente qué envía Stagehand antes de que vaya a cualquier lado.
- Controlás vos los reintentos, los timeouts y el ruteo.
Ese último punto resultó ser la herramienta de debugging más útil de toda esta investigación: un generate simulado que registra el prompt y tira error, a costo cero, es cómo se encontró la segunda llamada escondida de extract() mencionada arriba.
La lista misma vale la pena revisarla antes de elegir un modelo. Los nombres casi correctos fallan directamente: groq/llama-3.3-70b se rechaza porque el nombre real es groq/llama-3.3-70b-versatile. model: "auto", el valor que usa la documentación de Model Gateway, no es válido acá. Y dos modelos nombrados directamente en la propia documentación de Stagehand, groq/llama-3.3-70b-versatile y groq/llama-3.1-8b-instant, ya no existen en una cuenta activa de Groq; la lista es una foto que se desactualizó desde que se escribió.
Corrimos diez modelos contra la misma página. El barato perdió.
La expectativa inicial era que los modelos chicos y baratos iban a tener problemas con un árbol de accesibilidad real de 21.000 tokens. No fue así. Todos los modelos probados, desde claude-haiku-4-5 hasta gpt-5-nano y dos variantes distintas de Gemini, devolvieron el mismo xpath correcto. El modelo elegido cambió la velocidad y la verbosidad. No cambió la corrección.
| Modelo | Tokens de entrada | Tokens de salida | Tiempo |
|---|---|---|---|
anthropic/claude-haiku-4-5 | n/d | 41 | 1.860 ms |
openai/gpt-5-mini | 14.849 | 243 | 3.565 ms |
openai/gpt-5-nano | 14.821 | 856 | 14.030 ms |
google/gemini-3.5-flash | 17.301 | 165 | 4.807 ms |
google/gemini-3-flash-preview | 17.325 | 231 | 6.647 ms |
gpt-5-nano es el modelo más barato por token de esa tabla y el peor negocio: 20 veces más tokens de salida y 7.6 veces más lento que claude-haiku-4-5, razonando de más para una consulta de una línea, con la misma respuesta exacta. El más barato por token no es el más barato por tarea, y en un trabajo así de repetitivo, la verbosidad es todo el costo.
Si el presupuesto es la restricción real, la mejor combinación medida no fue un modelo de punta: fue Groq, acotado con locator. El plan gratuito de Groq tiene un tope de 8.000 tokens por minuto, que no alcanza para una sola llamada sin acotar de ~21.000 tokens; no es un límite de velocidad que se espera, la solicitud directamente no entra. Acotá esa misma llamada a page.locator("header") y baja a 770 tokens, y gpt-oss-20b de Groq responde correctamente en 613 ms. A un precio de unos $0.075 por millón de tokens de entrada, un observe() acotado en Groq cuesta unos $0.00015, del orden de 100 veces más barato que la misma llamada sin acotar en un modelo de punta. Cerebras, que sirve el mismo gpt-oss-120b, es 2.3 veces más caro en entrada y, peor, la cuenta con el supuesto "crédito gratis de $5" mostró un balance de $0.00, con cada llamada rechazada como payment_required. Nada estuvo disponible gratis ahí.
Dónde falla, y qué tan silencioso es al fallar
Los iframes cross-origin son invisibles, y no avisan nada
Shadow DOM funciona, incluso las raíces en modo cerrado. Los iframes del mismo origen funcionan; el xpath atraviesa directamente el nodo iframe[1] y act() hace clic adentro correctamente. Un iframe cross-origin es otra historia: nunca aparece en el snapshot de accesibilidad. observe() devuelve cero resultados, sin error, sin aviso.
Eso descarta, en silencio, justo los elementos que más se quieren automatizar: campos de checkout de Stripe, flujos de OAuth embebidos, widgets de hCaptcha, chat de soporte de terceros. Si una página tiene alguno de esos y tu observe() vuelve vacío, eso no es un error en tu instrucción.
act() también falla en silencio
Un selector desactualizado no tira error. act() devuelve success: false, y código que no chequea eso sigue corriendo como si el clic hubiera pasado. selfHeal arregla esto reinfiriendo la acción contra un árbol de accesibilidad fresco, a un costo real (21.410 tokens en una corrida medida), pero viene desactivado por defecto. Los xpath absolutos se vencen con un solo reload de página, así que cualquier estrategia armada sobre acciones cacheadas y reproducidas necesita selfHeal: true prendido explícitamente.
headless: true no puede funcionar, estructuralmente
Esto no es un bug que se vaya a parchear. El motor de Stagehand v4 corre como una extensión de Chrome, y las extensiones de Chrome no cargan en modo headless. No hay vuelta. La falla que aparece es un Failed to fetch a secas, un error que no menciona headless, ni extensiones, ni el navegador, así que se lee como un problema de red. Para CI o un servidor, la respuesta es Browserbase, no una bandera headless.
Una advertencia honesta para quien corra en local sobre una página pesada: en estas pruebas, aproximadamente una de cada cinco llamadas falló con ese mismo Failed to fetch, al azar, en un navegador local contra una página grande. Browserbase nunca mostró este comportamiento. Armá reintentos en cualquier cosa que corra contra un Chrome local; no asumas que una falla puntual significa que el código está mal.
Conectarte a tu propio navegador le instala una extensión
localBrowser.connect({ cdpUrl }) se conecta a un Chrome ya corriendo en unos 277 milisegundos, y Stagehand.create() después instala su extensión en ese navegador vía CDP, usando Extensions.loadUnpacked. Esto se confirma inspeccionando la lista de targets de CDP justo después de conectarse: la extensión queda ahí, de forma permanente, en ese perfil de navegador.
Eso significa que "conectar Stagehand a mi navegador" en silencio significa "agregar una extensión permanente a mi navegador", y esa extensión tiene acceso a lo que esté logueado ahí. Un puerto de Chrome DevTools no tiene autenticación propia, así que cualquier proceso local que pueda llegar a él puede manejar el navegador. Nada de esto tiene advertencia en la documentación de v4. Si vas a hacer una demo, usá un perfil descartable, no tu navegador de todos los días.
Veredicto
Para cualquier cosa que tenga que correr sin supervisión, en un horario fijo, en CI o en un servidor: Browserbase, no un navegador local. Headless es estructuralmente imposible en local, el caché y el ruteo de modelos solo funcionan por Browserbase, y la tasa de fallas de Failed to fetch en local hace que un navegador local sea una mala apuesta para cualquier cosa sin supervisión.
Para aprender la librería, prototipar, o un script que vas a mirar correr: Chrome local funciona bien, y es gratis. Solo acotá cada llamada con locator desde el arranque (no es una optimización para después, es casi un 95% de recorte de costo sin desventaja más allá de perder el caché en esa llamada), activá selfHeal si reproducís acciones cacheadas, y armá un reintento para el flakeo de Failed to fetch propio del navegador local.
No asumas que el modelo más barato listado es la corrida más barata. Probá una llamada antes de comprometer un pipeline entero a un modelo "económico"; uno verboso puede costar más en tokens de salida y en tiempo real que uno que cuesta más por token y dice menos.
Y no le pidas más de lo que hace. No ve adentro de iframes cross-origin. headless: true no funciona y nunca va a funcionar con esta arquitectura. Conectarte a un navegador real le instala algo de forma permanente. Ninguno de esos son casos límite que evitás con cuidado; son estructurales, y conviene saberlos el primer día en vez de encontrarlos a mitad de proyecto.
Qué significa esto si estás aprendiendo a construir estos sistemas
Cada número de esta nota salió de correr la herramienta y leer lo que devolvió, no de confiar en una página de documentación o en un tweet de lanzamiento. Esa es la habilidad real: instrumentar una caja negra a bajo costo (una función simulada que registra y tira error no cuesta nada y te dice todo), y después chequear una afirmación específica y falseable contra una corrida específica y reproducible. Es la misma postura que importa cuando sos vos quien decide si una librería, un modelo o el benchmark de un proveedor son seguros para construir encima. Si querés ir más profundo en arquitectura de agentes, evaluación, y el criterio de ingeniería detrás de decisiones como estas, eso es el centro del programa AI Engineering de 4Geeks Academy.
Preguntas frecuentes
¿Por qué stagehand.extract({ instruction, schema }) tira un error sobre instruction?
Porque extract() toma argumentos posicionales en v4, no un objeto. La llamada correcta es stagehand.extract("tu instrucción", schema, options). El error nombra instruction porque ese es el primer argumento posicional, y espera un string ahí, no un objeto.
¿Por qué mi uso de tokens en Stagehand es tan alto?
Cada llamada envía el árbol de accesibilidad completo de la página al modelo, que ronda los 21.000 tokens en una home moderna típica, sin importar qué tan simple sea tu instrucción. Acotá las llamadas con un locator apuntado a la parte relevante de la página; en nuestras pruebas eso recortó los tokens de entrada entre un 94% y un 98%, sin pérdida de precisión.
¿Por qué nunca acierta el caché de Stagehand?
cache.threshold viene por defecto en 10, lo que significa que la instrucción idéntica tiene que correr contra la página idéntica diez veces antes de que ocurra un acierto de caché. Es un contador de repeticiones, no un puntaje de similitud. Poné threshold: 1 para cualquier cosa que esperes correr más de una vez.
¿Puedo usar Stagehand con un modelo que no sea de OpenAI, Anthropic, Google, Groq o Cerebras?
Sí, con la opción model: { generate }, donde le pasás tu propia función que Stagehand llama en vez de un proveedor incorporado. Corre en Node, así que cualquier modelo alcanzable por HTTP funciona, incluyendo modelos locales.
¿Funciona headless: true en Stagehand v4?
No, y no puede funcionar. El motor de v4 corre como una extensión de Chrome, y las extensiones no cargan en modo headless. Usá Browserbase para cualquier cosa que necesite correr sin un navegador visible.
¿Por qué observe() no devuelve nada en una página con un iframe embebido?
Si ese iframe es cross-origin (un checkout de terceros, un widget de OAuth embebido, hCaptcha), es invisible para el snapshot de Stagehand por completo, sin ningún error. Los iframes del mismo origen y Shadow DOM, incluyendo raíces cerradas, ambos funcionan correctamente.
¿Es seguro conectar Stagehand a mi navegador Chrome personal?
Con cuidado. localBrowser.connect() instala la extensión de Stagehand en el navegador al que se conecta, de forma permanente, con acceso a lo que esté logueado ahí. Usá un perfil de navegador separado y descartable para pruebas, no tu navegador de uso diario.
