documentationpre-alpha

How to use heso.

Start with install, then read a page. The later chapters list the verbs, explain heso serve, show a few common flows, and cover replay and receipts. Use the sidebar when you only need one section.

~7,014 tokens · 7 chapters
chapter 01

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.
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 install keeps it isolated from 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 + linux + macos
$ powershell -ExecutionPolicy Bypass -c "irm https://github.com/blank3rs/heso/releases/latest/download/heso-cli-installer.ps1 | iex"
linux / macos / contributors

`cargo install` or build from source

You need Rust 1.90. 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, check the terminal error first — missing toolchain, missing build dependency, or a stale$PATH.

terminal · sanity check
$ heso --version
chapter 02

Your first read.

Point heso at a URL and it returns a JSON document with the page text, actions, forms, side effects, and hashes.
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

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

chapter 03

The command surface.

Commands and session methods, grouped by what they do to the world. Each one does one thing and returns JSON. Compose them yourself — there is no DSL or planner in heso.
group 01 of 07

Read

Fetch a URL and return JSON. These verbs do not mutate page state — they observe and project.

open

Whole 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…" }
read

Text, 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 }
ls

List 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" }, … ] }
cat

Polymorphic. 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": "…" }
tree

Fetch + 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": [ … ] }, … ] }
find

Filter 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" } ] }
meta

Structured 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": [ … ] }
group 02 of 07

Interact

Dispatch real DOM events. The page reacts the way it would if a human did it, then heso returns JSON describing what changed.

click

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

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

Submit 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…" }
wait

Wait 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 }
group 03 of 07

Eval

Run JavaScript either against a fetched page or in a standalone sandbox.

eval-js

Sandboxed 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-dom

Fetch 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"
group 04 of 07

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.

stamp

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

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

Pure 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: [...] }
group 05 of 07

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.

verify

Auto-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
info

Metadata 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…
seal

Wrap 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
unseal

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

Misc

Parallel reads and drift detection.

batch

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

Re-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
group 07 of 07

Tools

Local identity, the long-running JSON-RPC server, and the package-manager-aware updater.

identity

Generate 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…" }
serve

JSON-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 }
update

Detect 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
chapter 04

The protocol defines the verb shape.

Chapter 03 is the catalogue heso ships today. HESO/1.0, the underlying protocol, defines that catalogue as a closed core table. It also defines an extension namespace for reverse-DNS verbs implemented outside the core.
two tiers

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.
on the wire

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:

plan.json · three verbs, three namespaces
[
  { "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.

squat-defence

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.

honest status

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.

chapter 05

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. The wire format is JSON-RPC 2.0 over newline-delimited stdin/stdout.
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, 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 }
chapter 06

Recipes. Four shapes each.

Four common flows, each rendered as markdown, an XML-tagged prompt block, shell commands, and a JSON-RPC payload for heso serve.
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 07

Reproducibility.

heso keeps two mechanisms separate: seeded JavaScript for repeatable computation, and plats plus cassettes for replayable page observations. Live reads can still drift; recorded runs can be verified, replayed, and compared.
seeded javascript

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.

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.

page replay

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.

terminal - plats and receiptscapture - replay - verify
$ 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...
end of manual

That's the whole manual.
Source and terms below.

MIT · Apache 2.0 · v0.2.1terms & conditions →