Advanced

Single-pass Scripting

When you drive the browser one verb at a time, the model is stuck in the loop: every step means read the result, decide the next move, send another command. chrome-use script hands the whole observe / decide / act / verify flow to the daemon at once, so the decision logic runs next to the browser and you get structured results back in one tool call. Two forms: a JSON op-list (machine-generatable, dry-runnable) and a real synchronous JS program (cu.* helpers). Measured: crawling 3 HN pages for high-score stories drops from 13+ calls to one.

How expensive the per-verb loop is

chrome-use is one-command-one-action by default: snapshot to see the page, click @e8 to press a button, eval to read data. Each command is a full round-trip: process spawn, Unix socket to the daemon, extension relay to the browser, result back the same way, then the agent's model reads the output and decides the next step. Fine for a single step; the cost shows up in multi-step flows: pagination, conditional retries, using a prior result to pick the next action. The model is forced to stay in the loop, one inference and one tool call per step.

A fixed sequence of actions already runs in one round-trip with batch (press --hold and wait block inside the daemon, so timing is precise), but batch steps can't pass values between them, and there are no loops or conditions. script is the layer that adds them.

🪜 Four-rung ladder
single verb (one step, one round-trip) → batch (fixed sequence, one round-trip) → script (values between steps + loops + conditions + asserts, one round-trip) → stream WebSocket (continuous real-time driving). Each rung down expresses more logic with fewer round-trips and tool calls; pick the lowest one that's enough.

JSON op-list form

The first form is a JSON array of statements — good for a model to emit directly and for a program to assemble. A later op reads an earlier op's result through the {{name.path}} value bus:

bash
chrome-use script - <<'JSON'
[
  {"do":"navigate","url":"https://example.com"},
  {"do":"evaluate","script":"document.title","bind":"t"},
  {"assert":{"contains":["{{t.result}}","Example"]},"msg":"wrong page"},
  {"return":"{{t.result}}"}
]
JSON

There are eight statements (cli/src/native/script.rs):

{ "do": "<action>", ...params, "bind"?: "name", "optional"?: bool }   // run a command, store its result
{ "waitUntil": <cond>, "timeoutMs"?: n, "pollMs"?: n }                 // poll until a condition holds
{ "forEach": "{{list}}", "as"?: "item", "max": n, "body": [ ... ] }   // loop (max is required, a hard cap)
{ "assert": <cond>, "msg"?: "..." }                                   // assert, stop on failure (exit 1)
{ "set": "name", "value": <v|"{{expr}}"> }                            // assign
{ "push": "name", "value": <v|"{{expr}}"> }                           // append to an array
{ "return": <v|"{{expr}}"> }                                          // return and end
{ "log": <v|"...{{expr}}..."> }                                       // record a progress line

Conditions (<cond>): {"eq":[a,b]}, {"ne"}, {"contains"}, {"matches":[a,rx]}, {"exists":"<selector>"}, {"gone"}. The {{expr}} value bus: a string that is exactly {{x}} yields the value at its real type (a number stays a number); embedded ...{{x}}... does string interpolation; paths take indices, e.g. rows.0.ref, rows[0].url.

💡 Tip
evaluate's return value is wrapped under .result, so reference a page value as {{t.result}}, not {{t}}. --dry-run validates the program's shape without touching the browser; --arg k=v seeds a variable. Exit codes: 0 ok, 1 runtime failure or failed assert, 2 invalid program.

JS program form

The second form is a real JavaScript program that drives your already-logged-in Chrome through cu.* helpers. This is ego-lite's "code base" idiom; control flow is just JS's own for / if / break. No await: every cu.* call blocks until the browser returns, so the script is synchronous.

bash
chrome-use script --timeout 120000 <<'JS'
  cu.open('https://news.ycombinator.com');
  const out = [];
  for (let page = 0; page < 3; page++) {
    const rows = cu.eval("[...document.querySelectorAll('.athing')].map(r=>({id:r.id,title:r.querySelector('.titleline a')?.innerText}))");
    for (const r of rows) {
      const pts = cu.eval(`+(document.querySelector('#score_${r.id}')?.innerText.match(/\\d+/)?.[0] ?? 0)`);
      if (pts >= 100) out.push({ title: r.title, points: pts });
    }
    if (!cu.visible('a.morelink')) break;
    cu.click('a.morelink');       // full-page navigation, script state kept
    cu.waitFor('.athing', 8000);
  }
  return { count: out.length, top: out.slice(0, 5) };
JS

The helpers: cu.snapshot / cu.eval / cu.open / cu.click / cu.fill / cu.type / cu.press / cu.hover / cu.select / cu.check / cu.scroll / cu.find / cu.visible / cu.waitFor / cu.wait / cu.extract / cu.text / cu.log. cu.eval returns the page value directly; the rest throw on failure so the script fails fast. The program's return value is the command's result; cu.log(...) collects progress lines.

The engine runs in the daemon

ego can also write one JS program that runs a whole task; where chrome-use script pulls ahead is where the engine lives.

It survives hard navigations. A runtime injected into the page world dies the moment the page navigates — the whole execution context is gone, and multi-page flows die exactly at the page turn. chrome-use's JS engine runs in the daemon, outside the page: in the example above cu.click('a.morelink') triggers a full navigation, the script state (the out array, the loop counter) stays put, and cu.waitFor confirms the new page loaded before continuing.

Pure-Rust engine, every platform. The JS engine is boa, a pure-Rust implementation compiled into the chrome-use binary itself — no Node, no system V8. macOS / Linux / Windows all run it; ego is macOS-only.

Every op goes through the same front door. Each step of a script (both forms) re-enters the daemon's execute_command, the identical path a hand-typed single command takes: policy checks, the stealth transport, --humanize, @ref healing, cross-process session recovery. Nothing is reimplemented, so behavior inside a script matches the command line exactly. The full pipeline is on How It Works.

Measured: 3 HN pages

Benchmark task: paginate 3 pages of Hacker News, collect every story with ≥100 points, output JSON. The JS program above is the complete solution, verified on real Chrome (3 pages, 34 stories, ~4.8s).

per-verbchrome-use script
tool calls13+1
model inferencesone per step; every page turn and per-story score check goes back to the model2: write the program + read the result
intermediate outputevery snapshot / eval result enters the contextonly the final return JSON
pagination (hard nav)fine (each step is independent)fine, engine is in the daemon

The "≥100 points" decision is one if in the script; the old way carries every story's score back to the model to judge. The more steps and the denser the decisions, the wider the gap. Full landscape and the item-by-item ego-lite measurements are on how chrome-use compares.

Which layer to use

script doesn't replace single commands; it's the layer above for multi-step flows. How to choose:

✅ Start here
For pure data-scraping, look at extract --schema first (one call structures the page into JSON); to accumulate across pages or filter conditionally, wrap extract in a script loop.