Install
v0.2.1 ships prebuilt binaries for Windows x64, Linux x64 + arm64, and macOS x64 + arm64. Install through uv, pipx, pip, npm, GitHub releases, or cargo install. The installed tool is one Rust binary.`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 install keeps it isolated from 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 -ExecutionPolicy Bypass -c "irm https://github.com/blank3rs/heso/releases/latest/download/heso-cli-installer.ps1 | iex"
`cargo install` or build from source
You need Rust 1.90. 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, check the terminal error first — 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.
Handle partial page runs
Two flags come up often enough that they belong in chapter 02 rather than chapter 03. The first keeps partial runs visible as JSON; the second lets you define a missing global before the page's scripts execute.
--best-effortReturn JSON for partial failures- On open / read / wait. Output gains partial: true, partial_reason ("script_crash" | "wait_timeout" | "fetch_failed" | "parse_error" | "bot_challenge" | "non_html_content_type" | "http_<code>"), and failed_scripts: [...]. Use that output to decide the next call.
--inject-scriptShim a missing global before page scripts run- Takes inline JS or @file.js. Use it when a page expects a global that QuickJS did not load. Inject the stub before page scripts run, then retry.
The rest of the docs covers the verb catalogue (chapter 03), the protocol shape (chapter 04), JSON-RPC integration (chapter 05), common flows (chapter 06), and reproducibility (chapter 07).
The command surface.
Read
Fetch a URL and return JSON. These verbs do not mutate page state — they observe and project.
openWhole agent-shaped page view: title, description, metadata, tree, actions, plat_hash. Output is signed by default; --no-sign emits a bare plat, --lineage groups runs under one trust pin.
heso open <url> [--explore-links N] [--link-cap N] [--lineage LABEL] [--no-sign] [--key PATH]$ heso open https://example.com { "url": "https://example.com", "title": "Example Domain", "description": "…", "metadata": { … }, "tree": { … }, "actions": [ { "ref": "@e0", "role": "link", "name": "More information" } ], "plat_hash": "abf42b…" }
readText, actions, forms, cookies, console, framework, content_hash. --complete for lazy-loaded sites. Signed by default; --no-sign for a bare plat, --lineage to group runs.
heso read <url> [--complete] [--best-effort] [--js-fetch] [--inject-script <js|@file>] [--since <prev_hash>] [--lineage LABEL] [--no-sign]$ heso read https://nextjs.org/ { "title": "…", "text": "…", "actions": [ … ], "forms": [ … ], "cookies": [ … ], "console": [ … ], "framework": "next", "content_hash": "abf42b…", "lazy_hints": [], "partial": false }
searchMulti-source web search across rotating backends — Mojeek, Brave, Marginalia, DuckDuckGo, Wikipedia, optional SearXNG. No API key, no JS engine. A throttled backend is dropped into blocked[] and surfaced in errors[] with a typed code, never silently zeroed.
heso search <query> [--limit N] [--engines mojeek,brave,marginalia,ddg,ddg-lite,searxng,wiki] [--searx-url URL] [--timeout DUR]$ heso search "rust web scraping" { "query": "rust web scraping", "engines_used": [ "mojeek", "brave", "wiki" ], "results": [ { "rank": 1, "title": "…", "url": "…", "snippet": "…", "source": "mojeek" }, … ], "blocked": [ "ddg" ], "errors": [ { "code": "rate_limited", "engine": "ddg", "http_status": 202, "message": "…" } ], "knowledge": { "title": "…", "summary": "…", "url": "…" } }
lsList children at a tree path. Default path is `/`. Returns the heading-derived sections beneath that node.
heso ls <url> [path]$ heso ls https://example.com / { "path": "/", "entries": [ { "path": "/p1", "title": "…", "kind": "section" }, … ] }
catPolymorphic. A tree path returns `{ path, content }`; an `@ref` returns the full ElementRef JSON. Same verb, two address spaces.
heso cat <url> <path|@ref>$ heso cat https://example.com /p1 { "path": "/p1", "content": "…" }
treeFetch + build the full heading tree and print it as JSON. Agents cache this once, then ls / cat over it in-memory.
heso tree <url>$ heso tree https://example.com { "path": "/", "title": "Example Domain", "children": [ { "path": "/p1", "title": "…", "children": [ … ] }, … ] }
findFilter the action graph. --role, --name (case-insensitive substring), and --section compose.
heso find <url> [--role X] [--name SUBSTR] [--section /p]$ heso find https://example.com --role link --name "more" { "url": "https://example.com", "filters": { "role": "link", "name": "more" }, "count": 1, "matches": [ { "ref": "@e0", "role": "link", "name": "More information" } ] }
metaStructured page metadata: JSON-LD, OpenGraph, Twitter cards, SEO meta, canonical, icons, lang.
heso meta <url>$ heso meta https://example.com { "title": "Example Domain", "canonical": "…", "lang": "en", "open_graph": { … }, "twitter": { … }, "json_ld": [ … ], "icons": [ … ] }
Interact
Dispatch real DOM events. The page reacts the way it would if a human did it, then heso returns JSON describing what changed.
clickClick by @ref, visible text, CSS selector, or aria-label. Pass --js to run the page's scripts first, so handlers bound at runtime are live before the click.
heso click <url> @eN | --text "..." | --selector "..." | --aria-label "..." [--js]$ heso click https://news.ycombinator.com --text "More" { "ok": true, "op": "click", "final_url": "https://news.ycombinator.com/news?p=2", "redirects": [], "content_hash": "c12e89…" }
fillType into an input. Fires input + change events. Pass --js to run the page's scripts first, so listeners are wired before the value lands.
heso fill <url> @eN "<value>" [--js]$ heso fill https://example.com @e3 "hello" { "ok": true, "op": "fill", "ref": "@e3", "value": "hello", "selector": "input[name=q]", "element_id": null }
submitSubmit a form. Pre-fill named inputs with --field or --data. Returns the response status, URL, body, and parsed JSON when the server sent application/json.
heso submit <url> @eN [--field name=value]... [--data JSON]$ heso submit https://example.com @e9 { "ok": true, "op": "submit", "final_url": "https://example.com/results?q=…", "redirects": [], "content_hash": "9a3b21…" }
waitWait until a selector, text, URL, network-idle state, or timer matches.
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 }
Eval
Run JavaScript either against a fetched page or in a standalone sandbox.
eval-jsSandboxed JS, no DOM. --seed N makes Math.random() reproducible.
heso eval-js [--seed N] [--js-timeout DUR] "<expr>"$ heso eval-js --seed 42 'Math.random()' 0.5140492957650241
eval-domFetch the URL, run the page's scripts, then run your JS against the resulting DOM.
heso eval-dom [--seed N] [--js-fetch] [--js-timeout DUR] <url> "<js>"$ heso eval-dom https://example.com 'document.title' "Example Domain"
Plan
Plans, plats, cassettes, and replay. A plan is a JSON array of actions; a plat records the plan, observation, and network cassette. plat_hash is BLAKE3 over RFC 8785 canonical JSON, including the cassette.
stampExecute a plan against the live web and write a plat with the plan, cassette, and step log. Accepts a bare Action[], a plat with a `plan` field, or a TraceFingerprint. Pass --template <path> to run a template instead of a literal plan. The plat is signed by default; --no-sign emits a bare plat, --lineage groups runs under one trust pin.
heso stamp [--seed N] [--template <path>] [--lineage LABEL] [--no-sign] [--key PATH] <plan-or-plat.json>$ heso stamp plan.json > plat.json # plat.json — plan + observation + cassette, hashed together. # Exit 0 on a clean run; exit 1 with partial plat if a step failed.
runRe-execute the embedded plan against the plat's cassette, off-network. For an unchanged cassette the output plat_hash equals the input's (byte-identical replay, ADR 0008). Misses return `cassette miss` errors. Verifies the input plat's integrity by default and refuses a tampered plat (exit 1, error.code: "plat_integrity_mismatch"); --no-verify-input skips it. A signed input re-signs under the same lineage; --no-sign forces a bare output, --lineage regroups it.
heso run [--seed N] [--no-verify-input] [--lineage LABEL] [--no-sign] <plat.json>$ heso run plat.json > plat-replay.json # plat-replay.json — fresh plat over the deterministic re-run. # plat_hash equals the input plat's when the cassette is unchanged.
replayPure observation. Reads the recorded step log from a plat and prints it. No engine, no JS, no cassette lookup, no network. Pass --plan to emit just the plan field, ready to edit and pipe back into stamp.
heso replay <plat.json> [--plan]$ heso replay plat.json # summary on stdout: { steps_count, plat_hash, cassette_records, steps: [...] }
Audit
One polymorphic verifier, a metadata reader, and an Ed25519 envelope pair. verify and info sniff the file shape (plat / sealed plat / receipt / action-hash / template) and dispatch the right handler.
verifyAuto-detect via JSON sniffer, then verify. Handles plats, sealed plats, signed receipts, action-hash files, and templates. Bind to a signer: --expect-signer pins a fingerprint, --signer-key a public key, --known-signers a trust-on-first-use file (--accept-new-signer re-pins a lineage). --trusted-keys is the receipt allowlist.
heso verify [--expect-signer FP] [--signer-key PATH] [--known-signers PATH] [--accept-new-signer] [--trusted-keys PATH] <file>$ heso verify --expect-signer heso:79cdf778… plat.json OK abf42bb66917095eb4cafdd4deb00c068… · signer heso:79cdf778… # exit 0 valid · 1 invalid · 2 missing or malformed
infoMetadata for one file (plat, sealed plat, receipt, action-hash, template). Pass two files to diff them. --format json for structured output, --hash-only for pipes.
heso info [--format json] [--hash-only] <file> [other-file]$ heso info plat.json # plat · 17 actions · cassette 42.1 KB · sealed: no # plat_hash: abf42b… created: 2026-05-27T…
sealWrap a plat in an Ed25519 envelope using the local identity.
heso seal [--path identity.key] <file>$ heso seal plat.json > plat.sealed.json # plat.sealed.json — Ed25519 envelope wrapping the original plat # verify offline with: heso unseal plat.sealed.json
unsealVerify a sealed envelope offline. Pass --extract to emit the inner body (the original plat or receipt) on stdout.
heso unseal [--extract] <file>$ heso unseal plat.sealed.json OK fdibx2rLqGfrIf+duGbRKlM1iPwVSynHUq+nEisjwIE= # exit 0 valid · 1 invalid · 2 malformed
Misc
Parallel reads and drift detection.
batchOpen or read many URLs in parallel with a 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": "…", … }
refreshRe-stamp a plat against the live web and report drift. Exit 0 if the new plat_hash matches; exit 1 with details if the site changed.
heso refresh <plat.json>$ heso refresh plat.json { "plat_hash": "d93c08…", "drifted": false, "checked_at": "2026-05-27T…" } # exit 0 unchanged · 1 drifted
Tools
Local identity, the long-running JSON-RPC server, and the package-manager-aware updater.
identityGenerate or print a local Ed25519 keypair, with the short fingerprint to hand to verify --expect-signer. 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", "algorithm": "Ed25519", "public_key": "fdibx2…IE=", "fingerprint": "heso:79cdf778…" }
serveJSON-RPC over stdin / stdout. Cookies, DOM mutations, listeners, history persist across calls keyed by page_id. Chapter 05 shows a session and client snippets.
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 }
updateDetect the installer that owns the global heso binary (npm, PyPI tooling, Cargo, or Homebrew) and delegate the update to it.
heso update$ heso update # detected: npm (@ixla/heso) # running: npm install -g @ixla/heso@latest
The protocol defines the verb shape.
Core verbs, plus a reverse-DNS extension namespace
A HESO/1.0 verb name is either a bare lowercase token that the spec enumerates, or a reverse-DNS qualified name rooted in a domain the publisher controls. The split is fixed by the presence of a . — no dot means core, at least one dot means extension. There is no third category.
corebare lowercase token · [a-z][a-z0-9-]*- Defined in the spec’s §4 table. open, read, click, fill, submit, wait, stamp, run, replay, identity-init, identity-show, verify, info, seal, unseal. Adding or removing one is a spec-version event gated by an ADR.
extensionreverse-DNS · com.example.scrape-pricing- Names are rooted in domains. A publisher who controls example.com can define com.example.* verbs. A name with no dot is core and MUST be rejected if it isn’t in §4.
experimentalreserved prefix · ca.heso.x.*- Verbs reserved for heso project experiments before they become core. Promotion from ca.heso.x.foo to bare foo requires an ADR and a minor-version bump.
Extensions ride the same shape as core
A plat's plan array is {"verb": NAME, ...args}objects in execution order. An extension verb appears in that array exactly like a core one — the name carries the namespace, the rest of the object is whatever arguments the verb's publisher documented. No wrapper field, no envelope, no plugin manifest:
[
{ "verb": "open", "url": "https://shop.example.com/pricing" },
{ "verb": "com.example.scrape-pricing", "currency": "USD" },
{ "verb": "ca.heso.x.warc-export", "path": "out.warc" }
]A conformant HESO/1.0 implementation must accept every core verb the spec version lists. It may implement any extension verb it wants. What it must not do is silently ignore an unknown verb — the plat is rejected with a structured unknown verb: NAME error, same shape as the existing cassette-miss error. No partial execution, no ignored steps.
DNS ownership, not a registry
The reverse-DNS scheme borrows its squat-defence from the existing DNS ownership system the way Java packages, Maven group IDs, Android application IDs, and OCI image labels already do. com.example.scrape-pricing belongs to whoever controls example.com. If a publisher lets their domain lapse they lose naming authority. Implementations pinned to those verbs can keep working, and a new domain owner can claim the prefix.
To publish an extension, document the verb's input and output shape under your own domain, then provide a HESO/1.0 implementation that dispatches it. The spec does not require a particular path, a registry, or machine-readable discovery in v1.0.
What today's binary actually dispatches
The heso binary on your PATH ships the core verbs in chapter 03 and nothing else — third-party verb dispatch is not wired into the current release. Typing heso com.example.foo at the shell today returns an unknown subcommand error from the CLI parser, not a HESO/1.0 dispatch.
The extension tier is a property of the protocol, not of this binary at this version. A HESO/1.0 implementation that ships extension verbs — a future heso release, or another implementation in another language — can dispatch com.example.scrape-pricing. Until then, this chapter documents the protocol shape, not a current CLI feature.
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. The wire format is JSON-RPC 2.0 over newline-delimited stdin/stdout.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, 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,
"op": "click",
"final_url": "https://news.ycombinator.com/news?p=2",
"redirects": [],
"content_hash": "c12e89…" },
"id": 2 }Recipes. Four shapes each.
heso serve.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.
Seeded code is repeatable
Inside the eval-js sandbox, Math.random() becomes deterministic. That is the narrow compute layer: same seed, same expression, same sequence. Page-level reproducibility is handled by heso stamp and heso run, where the network cassette is part of the recorded plat.
$ 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.
Record the run, then replay it
heso stamp executes a plan against the live web and writes a plat: the plan, the observed step log, and the network cassette hashed together. heso run re-executes that embedded plan off-network against the cassette. If the cassette is unchanged, the replayed plat has the same plat_hash.
Plats are signed by default: each carries an Ed25519 sig and a lineage, so heso verify --expect-signer can bind a record to one fingerprint and reject a tampered or unexpectedly re-signed plat. Pass --no-sign for a bare plat or --lineage to group runs under one trust pin. heso does not make the live web deterministic; it records what happened so you can replay, hash, and verify that record — including who signed it.
$ heso stamp plan.json > plat.json # plan, observation, and cassette recorded together — signed by default $ heso run plat.json > plat-replay.json # offline cassette replay; unchanged input gives the same plat_hash $ heso verify --expect-signer heso:79cdf778... plat.json OK abf42b... · signer heso:79cdf778...
