browser_run — Writing Production Browser Automation Scripts
Read this whenever you need browser_run: a task that will be repeated (a skill, a recurring digital-human run), or a script whose logic is too long/complex to comfortably inline as a browser_evaluate one-liner. Source of truth: src/main/services/ai-browser/tools/script.ts.
1. Concept — browser_run vs browser_evaluate, and how they relate
Both tools ultimately call the exact same underlying method, BrowserContext.evaluateScript() (src/main/services/ai-browser/context.ts:1279-1305), which sends a Chrome DevTools Protocol Runtime.evaluate command with returnByValue: true, awaitPromise: true to the live page. The only real differences:
browser_evaluate | browser_run | |
|---|---|---|
| Source | Inline string in the tool call | A .js file read from disk |
| Argument passing | args: [{uid}] — resolves live DOM elements from the latest snapshot and passes them positionally | params: {...} — a plain JSON object, JSON.stringify'd and spliced into the expression text (context.ts:1283) — cannot carry a snapshot uid or any live reference, only data |
| Intended use | One-off exploration, edge cases dedicated tools can't handle | Repeatable, tested scripts — skills, recurring automation |
This means: use browser_evaluate to prototype. Since both paths run identical code, get the script body working as an inline browser_evaluate call first (fast iterate/fail loop, no file I/O), then save the working body into a .js file for browser_run once it's correct. Don't write a script file blind and debug it through browser_run's slower read-file-execute-report cycle.
When to reach for browser_run instead of the interaction tools or browser_evaluate
The default workflow is still browser_snapshot → browser_click/browser_fill/... → browser_snapshot (ai-browser/index.md §3) — that stays the right tool for a single, one-time interaction, because it gives the model a fresh accessibility snapshot to reason about after every step. Reach for a script instead when:
- The same multi-step sequence will run again — a skill, a recurring digital-human task, or anything you can already tell you (or another turn) will need to repeat verbatim. A
.jsfile under.claude/skills/<name>/is the reusable artifact; a chain of individual tool calls is not. - The logic needs real control flow — loops over an unknown number of DOM elements, conditional branching on page content, retry-until-condition — that's awkward to express as a sequence of discrete
browser_click/browser_fillcalls but natural as JS. - You need to read+compute+return structured data in one round trip (e.g. scrape a table, compute an aggregate) rather than snapshotting the DOM and doing the extraction/analysis in the agent's own reasoning — a script does the extraction inside the page context in one call.
Prefer the interaction tools (not a script) when the task is genuinely a single navigate-click- fill-submit flow done once — writing and whitelisting a file for that is more overhead than the tools it would replace, and you lose the re-snapshot-after-every-step safety net.
2. Required file format
async (params) => {
const items = document.querySelectorAll(params.selector)
return Array.from(items).map(el => el.innerText)
}- Exactly one top-level expression: a single arrow function,
asyncby convention (the tool description requires it; the underlying CDP call doesawaitPromise: trueeither way, so a synchronous return also resolves, but writeasync— that's the documented contract). - The tool wraps your file's content as
(<your content>)(<JSON.stringify(params)>)before sending it (context.ts:1283-1286) — do not self-invoke or add an IIFE wrapper yourself, and do not end the file with anything after the closing}of the function (no trailing comments). A single trailing;is fine — it's stripped automatically (script.ts:120-123, guards against(script;)(args)becoming aSyntaxError, a "common AI- generation mistake" per the code comment). paramsis always exactly one object argument ({}if the tool call omitsparams) — there is no way to receive multiple positional arguments the waybrowser_evaluate'sargsarray allows.- The script runs in the page's own JS context (real DOM,
window,fetch,localStorage) — not Node.js. Norequire(),import,fs,path, orBuffer(same environment asbrowser_evaluate, documented on that tool).
3. Return value
- Must be JSON-serializable (
returnByValue: trueon the CDP call does a structured clone). A DOM node, function, orundefinedvalue serializes to{}— extract the fields you need into a plain object/array before returning, don't return an element handle. - The tool result text wraps whatever you return in a fenced
```jsonblock after JSON- stringifying it (script.ts:146-150); a non-object return (string/number/boolean) is coerced withString(result)instead. - A thrown error (or a rejected promise, since the call awaits) surfaces as the CDP
exceptionDetails.exception.descriptionand is returned as a tool error:Script execution failed: <message>(script.ts:151-153,context.ts:1298-1302).
4. Call parameters
{ file: "/absolute/or/relative/path.js", params?: {...}, timeout?: number }file— must end in.js(checked literally; anything else is rejected before it's even read). Relative paths resolve againstctx.workDir— the current space's working directory, the same value passed at MCP-server-creation time for both the main-chat toolset and a digital human's scoped context alike (script.ts:90comment,toolsets/types.tsworkDir: stringis required on the scope). Absolute paths are used as-is.- Path whitelist — the resolved absolute path must be inside one of exactly two places, or the call is rejected before the file is even opened (
script.ts:100-110):- A
.claude/skills/directory rooted at$HOMEor at any ancestor ofworkDir(isUnderSkillsDir,script.ts:28-43— this specifically blocks a path like/tmp/.claude/skills/x.jsfrom qualifying just because it contains the marker string; the directory containing.claude/skills/must actually be$HOMEor an ancestor ofworkDir). workDiritself (or a subdirectory of it). A script anywhere else — e.g. an arbitrary path outside both — fails withPath not allowed.
- A
timeout— milliseconds, clamped to[1000, 120000], default60000(BROWSER_RUN_DEFAULT_TIMEOUT/BROWSER_RUN_MAX_TIMEOUT,script.ts:16-18, 133-136). This timeout only governs how long the tool call waits — it races the CDP call against asetTimeout(withTimeout,tools/helpers.ts:30-38) but sends no cancellation to the page. If your script is genuinely hung (e.g. an unresolvedfetch, an infinite loop) the timeout makes the tool return an error, but whatever is still running in the page keeps running; a page left in that state may needbrowser_navigate(a fresh load) before further tools behave normally.
5. Debugging workflow
- Iterate the logic as
browser_evaluatewith the same body (see §1) until it returns exactly what you expect against a live, already-navigated page. - Move the working body into a
.jsfile under the space's working directory or a.claude/skills/<name>/folder, wrapped asasync (params) => { ... }. - Call
browser_runwith a small, known-safeparamsobject first; check the returned JSON shape matches what step 1 produced. - If it fails at this stage but step 1 worked, the most likely causes are file-format mistakes (§2) — trailing content after
}, missingasync, or aparams.xreference that doesn't match the keys you're actually passing — not a browser/page problem, since the execution engine is identical to what you already validated. - On a timeout specifically: don't just retry blindly with a longer
timeout— first re-verify the same logic still completes promptly viabrowser_evaluateon the current page state; the page may have changed (redirected, a modal appeared) since you wrote the script. - When the script "fails" but the reported error doesn't explain why (a
fetch()inside the script rejected, an element your script expects was never there, a network response the page depended on never arrived), usebrowser_inspect— it's the only way to see past the script's own thrown message into what actually happened on the page:browser_inspect(target: "console")— surfaces JS errors/warnings the page itself logged (a framework error, an uncaught rejection elsewhere on the page) that your script's owntry/catchnever sees because they didn't happen inside your script's call stack.browser_inspect(target: "network"), optionally filtered byresourceTypes: ["fetch", "xhr"]— shows whether the request your script depended on (or triggered) actually went out, what status it got back, and its response body via the per-request detail call ({ target: "network", id }). This is the fast path for "my script'sfetch()returned something unexpected" instead of addingconsole.logstatements and re-running. Runbrowser_inspectimmediately after the failedbrowser_run/browser_evaluatecall, before any further navigation — it only reflects the current page's history, and a subsequentbrowser_navigateresets it.
6. Common mistakes worth naming
- Treating
paramsas if it werebrowser_evaluate'sargs. You cannot pass a snapshotuidthroughparamsand expect a live element —paramsis inert JSON. If a script needs to target a specific element found viabrowser_snapshot, express that as a selector/text string inparamsand re-locate it withdocument.querySelector/querySelectorAllinside the script, not as an element reference. - Forgetting the file must be
async. The tool's own contract requires it even though the CDP layer would technically tolerate a sync function — don't rely on that leniency, it's not part of the documented interface and could change. - Assuming a timeout aborts in-page work. It doesn't (§4) — plan scripts to have their own reasonable internal timeouts (e.g. on a
fetch) rather than relying on the tool's outer clamp to stop a runaway request. - Writing the script somewhere outside the whitelist (e.g. a scratch path under
/tmp) — it will be rejected outright, not merely warned about. Put reusable scripts under.claude/skills/<skill-name>/if they belong to a skill, or under the space's working directory otherwise.