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.
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:
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.
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.
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-verb | chrome-use script | |
|---|---|---|
| tool calls | 13+ | 1 |
| model inferences | one per step; every page turn and per-story score check goes back to the model | 2: write the program + read the result |
| intermediate output | every snapshot / eval result enters the context | only 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:
- One or two steps: a single verb, closed with
expector--observe. - Fixed sequence, no values passed between steps:
batch— precise timing, minimal overhead. - Need a prior result, a loop or condition, an assert to bail mid-way:
script. Let the model emit the JSON form (dry-runnable); hand-write or complex logic → the JS form. - Continuous real-time driving (games, frame-by-frame): the
streamWebSocket, see Canvas / WebGL.
extract --schema first (one call structures the page into JSON);
to accumulate across pages or filter conditionally, wrap extract in a script loop.
Daemon, relay, and where the boa engine runs — the reason script survives hard navigations is in this pipeline.
chrome-use script vs ego-lite's code-base idiom, measured item by item.
expect / --observe: the single-command verification that maps to assert / waitUntil in a script.