heso

The auditable layerfor the agent web.Every run becomes a signed, replayable record of what an agent did.

For agents that need to take real actions on real websites — read pages, fill forms, click buttons, hold sessions — and produce a signed, byte-identically replayable record of every move. Built in Rust; no Chromium, no Node. The artifact is the record.

replaybyte-identicalsigneded25519binary10.44 MBcold start~77 msengine~28 ms
live capture · 50 s · no edits

An agent doing real work.
No Chromium. No rendering pipeline.

fig. 02 — the verbs

One verb at a time.

Each does one thing and returns JSON. The agent composes them — no DSL, no internal planner. Page failures come back as structured data, and --best-effort plus --inject-script give you a way to inspect and retry partial runs.

A /
heso stamp · run · replay

Bundle, re-execute, observe action sequences.

A plan is a JSON array of canonical actions. A plat is an observation that embeds the plan it ran AND the recorded network cassette. stamp mints a plat from a plan, run re-executes off-network against the cassette (byte-identical replay per ADR 0008), and replay reads the recorded step log without re-executing — pass --plan to emit just the plan field for editing, then pipe back into stamp. plat_hash is BLAKE3 over RFC 8785 canonical JSON. It only excludes its own top-level plat_hash field; plan, observed content, and cassette bytes all contribute. Tamper with any of them and verification fails.

~/heso / stamp + replayzsh
$ heso stamp plan.json > plat.json
# plan -> plat, hashed together
$ heso replay plat.json
# per-step session log on stdout
$ heso replay --plan plat.json > plan-again.json
[ { "verb": "open", "url": "…" }, … ]
B /
signed by default · verify

Every run is signed. Verification names the signer.

heso identity init generates a local keypair and prints its fingerprint. From there open, read, stamp, and run carry an Ed25519 sig and a lineage with no extra flags — pass --no-sign for a bare plat or --lineage to group runs. heso verify --expect-signer binds a plat to one fingerprint; --known-signers pins trust on first use. Tamper with the plat and the signature, not just the hash, fails.

~/heso / signed by defaultzsh
$ heso identity init
{ "fingerprint": "heso:79cdf778…", "algorithm": "Ed25519" }
$ heso open https://example.com > plat.json
# plat carries sig + lineage · no flags needed
$ heso verify \
--expect-signer heso:79cdf778… plat.json
OK abf42b… · signer heso:79cdf778… ← exit 0
C /
heso click · fill · submit

Click a real link through real DOM events.

open returns interactive elements with stable @refs. click dispatches click, mousedown, mouseup through the QuickJS event loop. fill fires input and change. You can also locate by visible text, CSS selector, or aria-label. Add --jsto run the page's scripts first, so handlers bound at runtime are live before you act. The page reacts the way it would if a human did it.

~/heso / clickzsh
$ heso click https://news.ycombinator.com --text "More"
{ "ok": true, "op": "click", "final_url": "https://news.ycombinator.com/news?p=2" }
$ heso fill https://example.com @e3 "hello"
{ "ok": true, "op": "fill", "ref": "@e3", "value": "hello" }
D /
--seed N

Same seed, same Math.random.

Pass --seed N and Math.random()returns the same sequence across machines. Useful when a page's scripts seed off the RNG and you want a repeatable read. Not a sandbox guarantee — a reproducibility tool.

~/heso / eval-js --seedzsh
$ heso eval-js --seed 42 'Math.random()'
0.5140492957650241
$ heso eval-js --seed 42 'Math.random()'
0.5140492957650241 ← same seed, same number
$ heso eval-js --seed 99 'Math.random()'
0.5052084295432834
E /
heso batch · wait

Many URLs, one call. Block until ready.

batch runs many open / read calls in parallel over a shared cookie jar, streams JSON-Lines out. wait blocks until a selector exists, text appears, the URL matches, or the network goes idle — no polling loop in your agent code.

~/heso / batch + waitzsh
$ heso batch read url1 url2 url3 --parallel 2
# jsonl, one line per URL, shared cookie jar
$ heso wait https://app.example.com/ \
--selector-exists ".dashboard" --timeout 5s
{ "ok": true, "matched": "selector-exists" }
F /
heso serve

Use it from an agent framework.

heso serve exposes the same verbs over JSON-RPC on stdin/stdout. Cookies, DOM mutations, listeners, and history persist across calls — useful for login → navigate → scrape flows. Call it from Browser Use, Stagehand, or your own loop.

~/heso / servezsh
$ heso serve
# JSON-RPC over stdio
{ "jsonrpc": "2.0", "method": "open",
"params": { "url": "https://example.com" },
"id": 1 }
← { "result": { ... }, "id": 1 }
G /
--best-effort · --inject-script

Inspect and retry partial page runs.

When a page's scripts crash on QuickJS, --best-effort exits 0 anyway and returns what loaded plus partial: true, partial_reason, and failed_scripts. Use --inject-script to add a missing global before the page scripts run, then retry the same URL.

~/heso / --best-effort + --inject-scriptzsh
$ heso open https://shoelace.style --best-effort
{ "partial": true,
"partial_reason": "script_crash",
"failed_scripts": ["window.lunr is not defined"] }
$ heso open https://shoelace.style --best-effort \
--inject-script "window.lunr = (() => ({ Index: { load: () => ({}) } }))()"
{ "partial": false } ← shimmed, clean read
fig. 03 — the protocol

HESO/1.0 defines the verb shape.

The spec has a closed core of bare verb names. A conformant implementation dispatches those verbs. Extension verbs use reverse-DNS names under domains their publishers control, so implementations can add vocabulary without changing the core table.

  • specHESO/1.0 · open, royalty-free
  • core15 bare verbs · closed set
  • extendcom.example.scrape-pricing
  • dispatchany implementation, any language
core · closed15 verbs
  • open
  • read
  • click
  • fill
  • submit
  • wait
  • stamp
  • run
  • replay
  • identity-init
  • identity-show
  • verify
  • info
  • seal
  • unseal
extend · openreverse-DNS · own the domain
  • com.example.scrape-pricing
  • org.archive.warc-import
  • net.your-co.fill-shopify-checkout
~/heso / plan.jsonzsh
[
{ "verb": "open", "url": "https://shop.example/products" }, ← core verb
{ "verb": "com.example.scrape-pricing",
"selector": ".product-row" } ← your verb, your domain
]

The reference implementation ( heso ) ships the core verbs only — typing heso com.example.foo exits with unknown subcommand. Third-party verb dispatch is in the spec, not yet in this binary. Today, extension verbs require another HESO/1.0 implementation or a future heso release that dispatches them.

Five real titles.
Off the live wire.
Under 400 ms.

Fetched, parsed, JavaScript evaluated against the real DOM, returned as JSON. One Rust binary, end to end — the same process that ran the page can sign the receipt.

verb
eval-dom
exit
0 · ok
elapsed
397 ms · live
~/heso / eval-domzsh
$ heso eval-dom https://news.ycombinator.com \
'Array.from(document.querySelectorAll(".titleline > a"))
.slice(0,5).map(a => a.textContent)'
{
"ok": true,
"url": "https://news.ycombinator.com/",
"value": [
"The foundations of a provably secure OS (PSOS) (1979) [pdf]",
"GenCAD",
"Crystals found inside wreckage from the first nuclear bomb test",
"It is time to give up the dualism in the debate on consciousness",
"I turned a $80 RK3562 tablet into a Debian workstation"
]
}
exit 0397 ms · seed=42
fig. 04 — library

Or call it from your code.

The Python and Node packages ship the same Rust binary and a thin wrapper that spawns it. Subprocess + JSON is the contract — no FFI, no Node addon, no Python extension. The wrapper gives you a language API; the work still happens in the CLI process.

  • pythonsnake_case · sync
  • nodecamelCase · Promises
  • statesession() over a heso serve process
  • errorsHesoError · returncode · stderr
  • clisame binary on $PATH
pythonexample.py
$ uv tool install heso
import heso

page    = heso.open("https://example.com")
results = heso.search("rust web scraping", limit=5)
content = heso.read("https://example.com", complete=True)

# stateful flow over one long-lived `heso serve` process
with heso.session() as s:
    s.open("https://example.com")
    s.click(text="More information...")
    page = s.read()
nodeexample.ts
$ npm i @ixla/heso
import { open, search, read, session } from "@ixla/heso";

const page    = await open("https://example.com");
const results = await search("rust web scraping", { limit: 5 });
const content = await read("https://example.com", { complete: true });

// stateful flow over one long-lived `heso serve` process
await session(async (s) => {
  await s.open("https://example.com");
  await s.click({ text: "More information..." });
  const page = await s.read();
});
same surface, two idiomsdocs · session() · streaming reads coming
plugs intoharness-agnostic
  • python frameworksLangChain · Pydantic AI · LangGraph · smolagents · AgentScope
  • node frameworksMastra · Vercel AI SDK · LangGraph.js · Stagehand · Browser Use
  • skill markdownClaude Code · Cursor · Aider · Cline · Continue · Windsurf
  • cli spawningshell-script agents · homegrown loops · cron jobs
  • json-rpc stdioheso serve · long-running, stateful, many page_ids
fig. 05 — content_hash
01first readf3a8c91d2e7b…3942
02read --since f3a8…f3a8c91d2e7b…3942same. nothing moved.
03read --since f3a8…0c2e441a9bd5…c701actions_added: 2 · text_changed: true

Every read ends with a hash of what the agent actually saw. Hand it back next call and heso tells you what moved.

Know what moved.

Every heso read returns a content_hash. Pass it back next call with --since <prev_hash>and you get a delta instead of a re-read — no diffing JSON by hand, no re-reasoning over a page that didn't change.

  • actions_addednew clickables
  • actions_removedclickables gone
  • forms_changedinput shape shifted
  • text_changedvisible copy diff
  • title_changedtitle moved
fig. 06 — install

Pick your installer.

v0.2.1 ships prebuilt binaries for Windows x64, Linux x64 + arm64, and macOS x64 + arm64 — pip, npm, GitHub releases, and cargo install. Same Rust binary on every channel.

the verbs
  • openfetch + page summary
  • readfull content + actions
  • searchrotating web search · resilient
  • lslist children at a tree path
  • catcontent at a path or @ref
  • treefull heading tree as JSON
  • findfilter the action graph
  • metastructured page metadata
  • clickby @ref, text, or selector
  • filltype into an input
  • submitsubmit a form
  • waitblock on a condition
  • eval-jssandbox · --seed N
  • eval-domfetch + run your JS
  • stampplan → plat + cassette
  • runplat → plat, byte-identical
  • replayplat → step log · --plan
  • verifyauto-detect · trust the signer
  • infometadata · diff two files
  • sealed25519 envelope
  • unsealverify envelope · --extract
  • batchmany URLs in parallel
  • refreshre-stamp · detect drift
  • identityed25519 keypair · fingerprint
  • serveJSON-RPC over stdio
  • updatedelegate to your installer
01python
$ uv tool install heso
$ pipx install heso
$ pip install heso
02node
$ npm install -g @ixla/heso
$ npx @ixla/heso open https://example.com
03windows
$ powershell -ExecutionPolicy Bypass -c "irm https://github.com/blank3rs/heso/releases/latest/download/heso-cli-installer.ps1 | iex"
04macos / linux
$ curl --proto '=https' --tlsv1.2 -LsSf https://github.com/blank3rs/heso/releases/latest/download/heso-cli-installer.sh | sh
05cargo
$ cargo install --git https://github.com/blank3rs/heso heso-cli
MIT · Apache 2.0v0.2.1 shipped · signed by default · stamp · replay · verify