documentationpre-alpha

How to use heso.

Six chapters. Read them in order the first time — the second chapter assumes you ran the first command, the fourth assumes you understand what read returns. Skip around with the sidebar after that.

~4,136 tokens · 6 chapters
chapter 01

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.
python

`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.

  • uvuv tool install heso
  • pipxpipx install heso
  • pippip install heso
node

`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.

  • npmnpm install -g @ixla/heso
  • npxnpx @ixla/heso open https://example.com
windows

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 · windows x64
$ powershell -c "irm https://github.com/blank3rs/heso/releases/latest/download/heso.zip -OutFile heso.zip; Expand-Archive heso.zip -DestinationPath ."
linux / macos / contributors

`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.

terminal · cargo install
$ 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.

terminal · git clone + cargo build
$ git clone https://github.com/blank3rs/heso
$ cd heso
$ cargo build --release -p heso-cli
verify

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.

terminal · sanity check
$ heso --version
chapter 02

Your first read.

The smallest useful thing you can do with heso: point it at a URL and get back a JSON document an agent can reason about.
run this

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:

terminal · first run
$ heso read https://example.com > page.json
what came back

The read document

What you just wrote to page.json looks something like this (trimmed for clarity):

page.jsonone object · one URL
{
  "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
}
every field is one thing

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.
two flags worth knowing now

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).

chapter 03

The verbs. All seventeen.

Seventeen verbs, grouped by what they do to the world. Each one does one thing and returns JSON. Compose them yourself — there is no DSL and no internal planner.
group 01 of 06

Find

Discover content. The five verbs that take a URL or a query and return JSON without changing the world.

open

Fast 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" } ] }
read

Full 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 }
batch

Open 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": "…", … }
wait

Block 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 }
group 02 of 06

Act

Drive interactions. Each verb dispatches real DOM events and returns a receipt of what changed.

click

Click 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…" }
fill

Type 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" }
submit

Submit 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…" }
group 03 of 06

Eval

Run your own JavaScript. One verb fetches a page first, the other doesn't.

eval-dom

Fetch 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-js

Sandboxed JS, no DOM. --seed N makes Math.random() reproducible.

heso eval-js [--seed N] "<expr>"
$ heso eval-js --seed 42 'Math.random()'
0.5140492957650241
group 04 of 06

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.

stamp

Execute 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.
replay

Re-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.
unpack

Extract 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" }
]
group 05 of 06

Trust

Ed25519 keypairs + signed receipts. Every open / read can emit a receipt; recipients verify against an allowlist of trusted pubkeys.

identity

Generate 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-verify

Verify 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
group 06 of 06

Serve

One long-running stateful session. Cookies, DOM mutations, listeners, history persist across calls.

serve

JSON-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 }
chapter 04

Integrate with your agent loop.

When your agent runs more than one verb per page, spawn 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.
the wire

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
working example

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 }
chapter 05

Recipes. Four shapes each.

Four common flows. Each one comes in four shapes — markdown for docs, an XML-tagged block for Cursor / Claude / Aider, a raw shell command, and a JSON-RPC payload for heso serve. Pick the recipe, pick the shape, paste.
step 1 · pick a recipe

What do you want to do?

step 2 · pick a shape

Where will you paste it?

01 / 04 · Search, then batch read
# 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.
~96 tokens · 12 lines · ready
chapter 06

Reproducibility.

heso has one knob for repeatable output: --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.
what --seed controls

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.

terminal · seeded eval-jssame seed = same result
$ 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.

why this is enough for now

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).

end of manual

That's the whole manual.
Go ship.