Install
v0.0.4 ships Windows-x64 binaries on every registry. Linux and macOS install via cargo. heso is one Rust binary — no Node, no Chromium, no headless driver.`uv`, `pipx`, or `pip`
Any one of them will fetch the wheel from PyPI, drop the heso binary on your PATH, and expose the same Python API. uv tool installis the recommended path — fast, isolated, and won't collide with system Python.
- uv
uv tool install hesorecommended - pipx
pipx install heso - pip
pip install heso
`npm` or `npx`
The npm package @ixla/heso bundles the same binary and exposes a TypeScript / JavaScript API. npx works without installing — useful for a one-off call from a script.
- npm
npm install -g @ixla/heso - npx
npx @ixla/heso open https://example.comone-shot, no install
Direct binary download
If you'd rather avoid a package manager, grab the zip straight from the latest release. The PowerShell snippet below downloads, extracts, and leaves heso.exe in the current directory.
$ powershell -c "irm https://github.com/blank3rs/heso/releases/latest/download/heso.zip -OutFile heso.zip; Expand-Archive heso.zip -DestinationPath ."
`cargo install` or build from source
You need a stable Rust toolchain (1.80+). cargo install fetches the crate, builds, and drops heso on your PATH.
$ cargo install --git https://github.com/blank3rs/heso heso-cli
Or clone the repo and build in place — the release artifact lands in target/release/heso.
$ git clone https://github.com/blank3rs/heso$ cd heso$ cargo build --release -p heso-cli
Confirm the binary runs
If a version prints, you're ready for chapter 02. If not, the error in your terminal is the place to start — missing toolchain, missing build dependency, or a stale$PATH.
$ heso --version
Your first read.
One command, one document
readfetches the URL, runs the page's JavaScript in a sandboxed engine, and writes one JSON object to stdout. Redirect it to a file:
$ heso read https://example.com > page.jsonThe read document
What you just wrote to page.json looks something like this (trimmed for clarity):
{
"url": "https://example.com",
"title": "Example Domain",
"text": "Example Domain. This domain is for use in…",
"actions": [
{ "ref": "@e0", "role": "link", "name": "More information",
"href": "https://www.iana.org/domains/example" }
],
"forms": [],
"cookies": [],
"console": [],
"framework": null,
"content_hash": "abf42bb66917095eb4cafdd4deb00c068…",
"lazy_hints": [],
"partial": false
}What's actually in there
Read top to bottom — the order is roughly how often you'll touch each one.
title · textWhat a human reads- The page title and the rendered visible text after scripts have run. Everything else is for the agent.
actionsClickable / fillable elements- Each has a stable @ref (@e0, @e1, …) you can pass to click, fill, or submit. Refs survive across actions on the same page.
formsForm schemas- One entry per form: inputs with their refs, the submit ref. Use it to plan a fill+submit without re-reading the page.
cookies · consoleSide effects heso captured- Cookies the page set, anything it logged to console.* — useful when scripts misbehave.
framework"next" | "react" | "vue" | null- heso's best guess at the rendering stack. null when it can't tell.
content_hashFingerprint of what came back- Pass it next call as --since <hash> and heso returns a delta describing what changed (actions_added, actions_removed, forms_changed, text_changed, title_changed).
lazy_hints["infinite_scroll", "load_more_button", …]- Non-empty when heso thinks more content is hiding. Re-run with --complete to loop "fire pending observers → click load-more → wait for DOM to settle" until the page stops changing.
partialfalse on a clean run- Combined with --best-effort, becomes true plus a partial_reason and failed_scripts list when scripts crash, waits time out, fetches fail, or parsing breaks — instead of a non-zero exit.
Survive sites that misbehave
Two flags come up often enough that they belong in chapter 02 rather than chapter 03. The first turns crashes into structured output; the second prevents the crash in the first place.
--best-effortExit 0 on partial failures- On open / read / wait. Output gains partial: true, partial_reason ("script_crash" | "wait_timeout" | "fetch_failed" | "parse_error"), and failed_scripts: [...]. Your agent decides what to try next instead of dying on a non-zero exit.
--inject-scriptShim a missing global before page scripts run- Takes inline JS or @file.js. Use it when sibling scripts cascade — the canonical case is a page where script A reads window.lunr that script B was supposed to set. Inject the stub, the cascade resolves, the page works.
The rest of the docs is the catalogue of verbs (chapter 03), how to script them from your agent (chapter 04), four common flows (chapter 05), and the one knob heso has for reproducible output (chapter 06).
The verbs. All seventeen.
Find
Discover content. The five verbs that take a URL or a query and return JSON without changing the world.
searchWeb search via DuckDuckGo + Wikipedia. No API key.
heso search "<query>" [--limit N]$ heso search "rust web scraping" --limit 5 { "query": "rust web scraping", "results": [ { "title": "…", "url": "…", "snippet": "…" }, … ] }
openFast page summary — title, headings, actions.
heso open <url>$ heso open https://example.com { "title": "Example Domain", "headings": [ … ], "actions": [ { "ref": "@e0", "role": "link", "name": "More information" } ] }
readFull picture: text, actions, forms, cookies, console, framework, content_hash. --complete for lazy-loaded sites.
heso read <url> [--complete] [--best-effort] [--since <prev_hash>]$ heso read https://nextjs.org/ { "title": "…", "text": "…", "actions": [ … ], "forms": [ … ], "cookies": [ … ], "console": [ … ], "framework": "next", "content_hash": "abf42b…", "lazy_hints": [], "partial": false }
batchOpen or read many URLs in parallel. Shared cookie jar. JSON-Lines out.
heso batch [open|read] <urls...> [--parallel N]$ heso batch read u1 u2 u3 --parallel 3 { "url": "u1", "title": "…", … } { "url": "u2", "title": "…", … } { "url": "u3", "title": "…", … }
waitBlock in the binary until a predicate is true. No polling loop in your agent.
heso wait <url> --selector-exists ".x" | --text-contains "..." | --url-matches "..." | --network-idle | --time 5s$ heso wait https://app.example.com/ --selector-exists ".dashboard" --timeout 5s { "ok": true, "matched": ".dashboard", "elapsed_ms": 612 }
Act
Drive interactions. Each verb dispatches real DOM events and returns a receipt of what changed.
clickClick by @ref, visible text, CSS selector, or aria-label.
heso click <url> @eN | --text "..." | --selector "..." | --aria-label "..."$ heso click https://news.ycombinator.com --text "More" { "ok": true, "navigated": "/news?p=2", "content_hash": "c12e89…" }
fillType into an input. Fires input + change events.
heso fill <url> @eN "<value>"$ heso fill https://example.com @e3 "hello" { "ok": true, "ref": "@e3", "value": "hello" }
submitSubmit a form. Returns the next page's content_hash.
heso submit <url> @eN$ heso submit https://example.com @e9 { "ok": true, "navigated": "/results?q=…", "content_hash": "9a3b21…" }
Eval
Run your own JavaScript. One verb fetches a page first, the other doesn't.
eval-domFetch the URL, run the page's scripts, then run your JS against the resulting DOM.
heso eval-dom <url> "<js>"$ heso eval-dom https://example.com 'document.title' "Example Domain"
eval-jsSandboxed JS, no DOM. --seed N makes Math.random() reproducible.
heso eval-js [--seed N] "<expr>"$ heso eval-js --seed 42 'Math.random()' 0.5140492957650241
Bundle
Plans, plats, and the edit/replay loop. A plan is a JSON array of canonical actions; a plat is an observation that embeds the plan it ran. plat_hash is BLAKE3 over RFC 8785 canonical JSON.
stampExecute a plan against the live web and mint a fresh plat. Accepts a bare Action[], a plat with a `plan` field, or a TraceFingerprint.
heso stamp [--seed N] <plan-or-plat.json>$ heso stamp plan.json > plat.json # plat.json — the plan + the observation, hashed together. # Exit 0 on a clean run; exit 1 with partial plat if a step failed.
replayRe-execute the embedded plan and print a per-step session log. No plat output — use stamp for that. Stateful: one JsSession carries DOM mutations and cookies across steps.
heso replay [--seed N] <plat.json>$ heso replay plat.json # step log on stdout: each verb, what changed, exit per step.
unpackExtract just the `plan` field from a plat. Edit it standalone and pipe back into stamp to re-mint.
heso unpack <plat.json>$ heso unpack plat.json > plan-again.json [ { "verb": "open", "url": "https://news.ycombinator.com/" }, { "verb": "click", "ref": "@e3" } ]
Trust
Ed25519 keypairs + signed receipts. Every open / read can emit a receipt; recipients verify against an allowlist of trusted pubkeys.
identityGenerate or print a local Ed25519 keypair. Default path: heso-local-data/identity.key.
heso identity init [--path P] | heso identity show [--path P]$ heso identity init { "path": "heso-local-data/identity.key", "public_key": "fdibx2…IE=", "algorithm": "Ed25519" }
receipt-verifyVerify a signed receipt. Pass --trusted-keys to bind to an allowlist. Receipts with mode: live are rejected per ADR 0008.
heso receipt-verify [--trusted-keys allowlist.json] <receipt.json>$ heso receipt-verify --trusted-keys trusted.json receipt.json OK fdibx2rLqGfrIf+duGbRKlM1iPwVSynHUq+nEisjwIE= # exit 0 valid · 1 invalid · 2 missing or malformed
Serve
One long-running stateful session. Cookies, DOM mutations, listeners, history persist across calls.
serveJSON-RPC over stdin / stdout. The full integration story is in chapter 04.
heso serve$ heso serve # stdin ← { "jsonrpc": "2.0", "method": "read", # "params": { "url": "…" }, "id": 1 } # stdout → { "jsonrpc": "2.0", # "result": { "page_id": "p_a4f0", "title": "…", … }, # "id": 1 }
Integrate with your agent loop.
heso serve once and talk to it over stdio. State lives in a page_id — cookies, DOM mutations, listeners, history all persist across calls. Point Browser Use, Stagehand, mcp-browser, or your own loop at it.What `heso serve` speaks
JSON-RPC 2.0 over stdin / stdout, one JSON object per line. Errors on stderr. Requests are independent — fire many in flight, match responses by id.
- transport
- stdio · jsonl frames
- protocol
- JSON-RPC 2.0
- state
- keyed by page_id
- cancellation
- drop the request id
- concurrency
- many page_ids, one process
- persistence
- cookies · dom · listeners
Open · read actions · click by text
Three tabs: the raw RPC session you'd see on the wire, a TypeScript client you can paste into a Node project, and the equivalent in Python. The flow is the same — open a page, read its actions, click one by visible text.
# spawn heso serve as a subprocess of your agent
$ heso serve
# step 1: open the page (or read, if you want full content)
{ "jsonrpc": "2.0", "method": "open",
"params": { "url": "https://news.ycombinator.com" },
"id": 1 }
{ "jsonrpc": "2.0",
"result": { "page_id": "p_a4f0",
"title": "Hacker News",
"actions": [ { "ref": "@e0", "role": "link",
"name": "Hacker News" },
{ "ref": "@e220", "role": "link",
"name": "More" } ] },
"id": 1 }
# step 2: click by visible text — no locator step required
{ "jsonrpc": "2.0", "method": "click",
"params": { "page_id": "p_a4f0", "text": "More" },
"id": 2 }
{ "jsonrpc": "2.0",
"result": { "ok": true,
"navigated": "/news?p=2",
"content_hash": "c12e89…" },
"id": 2 }Recipes. Four shapes each.
heso serve. Pick the recipe, pick the shape, paste.What do you want to do?
Where will you paste it?
# Search, then batch read search returns a list of URLs without an API key. batch read fans them out across one shared cookie jar and streams JSON-Lines back. ```bash heso search "rust web scraping" --limit 5 heso batch read <url1> <url2> <url3> --parallel 3 ``` **Why:** Two commands, full content. The shared cookie jar means downstream pages see the same session as the first.
Reproducibility.
--seed N on eval-js. Same seed, same expression, same machine or another — same result. It's narrow on purpose. Real pages over a real network are not reproducible, and heso doesn't pretend otherwise.One knob, one effect
Inside the eval-js sandbox, Math.random()becomes deterministic. That's it — no virtual clock, no hashed page output. Useful when you want to exercise code paths that touch randomness without having to mock them out. Signed receipts ride on a separate flag (--receipt PATH); see chapter 03 under Trust.
$ heso eval-js --seed 42 'Math.random()'0.5140492957650241$ heso eval-js --seed 42 'Math.random()'0.5140492957650241$ heso eval-js --seed 7 'Math.random()'0.3712389501023784
Different seeds give different sequences. Drop the flag and you get system randomness back.
Pin the inputs, not the outputs
Most reproducibility wins come from pinning inputs, not from hashing outputs. Pin the URL, pin the seed where randomness matters, capture the JSON heso prints. That gives you a test fixture you can diff against future runs — without claiming properties the engine doesn't actually have today.
If you need page-level reproducibility, the closest thing heso ships is the content_hash on read — a fingerprint you can pass back as --since <hash> to get a structured delta of what changed between calls. That covers drift detection. For signed provenance, pair open / read with --receipt PATH — every call mints an Ed25519-signed receipt that heso receipt-verify checks against a trusted allowlist (per ADR 0005 + 0008).
