Skip to content

Headless clients

A headless client is the code that produces a valid payload for one service, without a browser. Write it once in Rust against the toolkit’s crates. The build produces node, python, go and rust packages that drive the same compiled binary.

Three layers:

  • The client crate, one per target under clients/<id>, declares its ops and capabilities and implements the Client trait from wre-client.
  • The host binary, wred from wre-clientd, owns the protocol, worker threads, sessions, injected services and diagnostics recorder. Clients compile in behind cargo features.
  • The language packages, generated by wre-codegen from the binary’s descriptor, on top of a hand-written runtime shim per language in packages/.

the sidecar protocol is the wire contract between the last two.

Terminal window
wre client new acme --summary "Seals payloads for acme"

This writes clients/acme with a skeleton, adds a conformance suite stub, and wires the crate into wred behind a target-acme feature. The skeleton’s solve returns unsupported until you write it.

clients/example mounts a script in a V8 realm, finds its primitives by signature, and seals a payload with them. clients/altcha is the first real target: it needs no realm, ports the target’s four key derivations to Rust, and is cross-checked against values the widget’s own code produced (ALTCHA research note).

A descriptor. It names the ops, their parameter and result shapes, config shape, events and capabilities. See the shape table in the sidecar protocol.

pub fn describe() -> ClientDescriptor {
ClientDescriptor::new("acme", env!("CARGO_PKG_VERSION"))
.summary("Seals a payload with the collector's own primitives")
.primary("solve")
.notes(NOTES)
.capabilities(Capabilities {
needs_v8: true,
needs_network: true,
stateful: true,
concurrency: Concurrency::PerSession,
warmup_ms: 150,
..Capabilities::default()
})
.config(Shape::object("AcmeConfig", [
field("proxy", Shape::optional(Shape::Str)),
field("fingerprint", Shape::optional(Shape::Str)),
field("timeout_ms", Shape::Int).with_default(json!(30_000)),
]))
.op(OpSpec::new("solve", facts_shape(), solved_shape())
.summary("Build, encode and seal a payload")
.deadline_ms(20_000)
.streams(&["progress"]))
.event(EventSpec::new("progress", progress_shape()))
}

An Object or Enum shape becomes a named type in every generated package. Two different shapes sharing a name is an error at registration time.

The descriptor is also the only source the package READMEs have. wre client package turns the config shape and every op into reference tables, so a field(...).summary(...) you skip is a blank cell on npm and PyPI. primary names the op the generated examples call, which otherwise is the first op that takes arguments. notes is markdown that lands in the README of all four languages, under the quickstart and above the tables, so keep it prose: a code block there would be in the wrong language for three of them.

The client itself. One struct, built once per session, holding whatever is expensive: mounted realm, cookies, counters.

impl Client for Acme {
fn call(&mut self, op: &str, params: Value, call: &Call) -> ClientResult<Value> { ... }
fn warmup(&mut self, call: &Call) -> ClientResult<()> { ... }
fn health(&mut self) -> ClientResult<Value> { ... }
fn diagnostics(&mut self) -> Value { ... }
fn close(&mut self) -> ClientResult<()> { ... }
}

Parameters arrive validated against the declared shape with defaults filled in.

A registration. pub fn registration() -> Registration { Registration { id: ID, describe, build } }, where build takes a Ctx and the validated config and returns the boxed client.

A client never opens a resource. Ctx hands it:

  • http(proxy), a blocking wrapper over wre-net’s client, pooled per proxy, fingerprint and user agent.
  • http_with(HttpOptions), the same client with the transport spelled out. fingerprint takes a profile[:platform] spec such as chrome_141:windows and sets the TLS handshake, the HTTP/2 settings and the default headers, user agent included. Set user_agent on its own and the nearest profile for that agent is used, so the header and the handshake do not contradict each other. Set neither and the client emulates Chrome 140 on macOS. A single request can override the choice with FetchRequest::emulating(fingerprint).
  • now_ms() and random_u64(), both overridable.
  • store() and session_store(), file backed key value scratch under the host’s state directory.
  • metric(name, value) and count(name), which show up in the metrics op.
  • diag(), the session’s recorder.

Per call, Call carries the deadline, cancel flag, event sink and binary part of the request:

call.check()?;
call.progress(2, 3, "sealing");
call.debug("sealed", json!({ "bytes": bytes }));
call.set_output(bytes);

check fails the call when cancelled or past deadline, progress sends an event, debug leaves a breadcrumb, and set_output attaches the binary part of the response.

Call call.check() between steps in long running work; a V8 realm’s own execution timeout is the backstop for scripts that don’t yield.

needs_v8, needs_chrome, needs_network, stateful, warmup_ms and concurrency are read by the host. Concurrency::PerSession is the default. SingleThread pins every session for that target to one worker.

Return the kind that matches what happened (see the sidecar protocol). target_drift means the shipped script no longer matches this client. blocked means the service answered with a challenge.

Add the crate to the workspace (clients/* is a member glob), then wire it into the host.

In crates/wre-clientd/Cargo.toml:

[features]
default = ["target-example"]
target-example = ["dep:wre-client-example"]
target-acme = ["dep:wre-client-acme"]

In crates/wre-clientd/src/registry.rs:

#[cfg(feature = "target-acme")]
registry.register(wre_client_acme::registration())?;

clients.toml at the workspace root maps targets and platforms to a binary:

[bundle.default]
targets = ["example"]
platforms = ["aarch64-apple-darwin", "x86_64-unknown-linux-gnu", "x86_64-pc-windows-msvc"]

A static V8 build is 30 to 50 MB. A bundle whose targets all declare needs_v8 = false can be built without the V8 feature.

  • wre client bundles lists what is declared.
  • wre client build --bundle default --sign cross builds into dist/default/bin/<triple>.
  • wre client list shows what the built binary carries.
  • wre client describe example prints ops, events and capabilities.
  • wre client package --bundle default writes the four packages.
  • wre client test --lang all runs the conformance suite through each binding.
  • wre client publish --bundle default prints the publish commands and runs nothing.

wre client build --debug --platform aarch64-apple-darwin reuses the debug profile and skips cross builds.

Cross building needs the rust target installed (rustup target add ...) and a linker. --zig switches to cargo-zigbuild, which covers the linux triples from a mac. --sign runs an ad hoc codesign over the apple binaries; without it, an unsigned arm64 mac binary will not execute.

wre client package reads dist/<bundle>/bin, hashes every binary it finds, asks the host binary for its descriptor, and writes:

dist/default/packages/
node/example/ index.js, index.d.ts, package.json, platform/<tag>/bin/wred
python/example/ wre_client_example/, setup.py, build_wheels.sh, binaries/<triple>/wred
go/example/ client.go, types.go, meta.go, go.mod
rust/example/ src/lib.rs, Cargo.toml

Node ships the binary as platform specific optional dependencies. Python builds one platform tagged wheel per triple through build_wheels.sh. Go downloads the binary on first use against the sha256 baked into meta.go. Rust links wre-client and spawns the sidecar.

Every generated package pins the schema hash and refuses to run against a binary that reports a different one.

wre client publish prints the commands in the order they run: npm platform packages before the package that depends on them, build_wheels.sh before twine upload, the go module tag, and cargo publish last. It runs none of them.

One suite per target in conformance/<id>.json. A case names an op, its params and what it expects:

{
"target": "example",
"config": { "clock_ms": 1700000000000, "seed": 7 },
"cases": [
{ "name": "the hash is stable", "op": "hash", "params": { "text": "abc" },
"expect": { "value": 440920331 } },
{ "name": "a deadline stops a stalled call", "op": "stall", "params": { "ms": 4000 },
"deadline_ms": 300, "expect_error": "timeout" }
]
}

expect is a subset match on an object result and an exact match otherwise, expect_keys asserts presence, expect_error asserts the error kind. Fixing the clock and seed in config makes a solver deterministic.

The runners live in packages/<language>/conformance and print one json summary; wre client test --lang all reports node, python, go and rust side by side.

Each session records structured events (calls, outcomes, durations, client breadcrumbs) and a set of facts, and writes a single report file on failure by default.

Set WRE_DIAG=always to record every call:

Terminal window
WRE_DIAG=always node app.js

The host’s own log is separate. It goes to the sidecar’s stderr, which every binding discards so a library stays quiet inside someone else’s process. WRE_STDERR=inherit attaches it to the calling process and WRE_LOG=debug raises the level.

wre client diag <path> reads a report back:

Terminal window
wre client diag artifacts/clients/diagnostics/example-2026-08-15T17-13-25Z-s1.diag.json

A failing call carries the path in error.detail.diagnostics. Every binding exposes diagnose() for producing one on demand.

What lands in the file: handshake facts, client version, scrubbed config, environment, call counters, event ring, failure, and a client section the target fills in. Fill it with whatever belongs in a bug report:

fn diagnostics(&mut self) -> Value {
let records = self.mount.realm.records().unwrap_or_default();
json!({
"build": self.build_tag,
"source_sha": self.source_sha,
"roles": self.mount.roles(),
"realm": { "console": records.console, "errors": records.errors },
"state": { "solved": self.solved },
})
}

Credential-looking keys are replaced by a length and digest; long strings are truncated with their sha256 kept. Call parameters are summarised as key names and digests unless the session asks for include_params. Reports are capped by max_events and pruned to keep_files.