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 theClienttrait fromwre-client. - The host binary,
wredfromwre-clientd, owns the protocol, worker threads, sessions, injected services and diagnostics recorder. Clients compile in behind cargo features. - The language packages, generated by
wre-codegenfrom the binary’s descriptor, on top of a hand-written runtime shim per language inpackages/.
the sidecar protocol is the wire contract between the last two.
Writing a client
Section titled “Writing a client”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.
What the host injects
Section titled “What the host injects”A client never opens a resource. Ctx hands it:
http(proxy), a blocking wrapper overwre-net’s client, pooled per proxy, fingerprint and user agent.http_with(HttpOptions), the same client with the transport spelled out.fingerprinttakes aprofile[:platform]spec such aschrome_141:windowsand sets the TLS handshake, the HTTP/2 settings and the default headers, user agent included. Setuser_agenton 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 withFetchRequest::emulating(fingerprint).now_ms()andrandom_u64(), both overridable.store()andsession_store(), file backed key value scratch under the host’s state directory.metric(name, value)andcount(name), which show up in themetricsop.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.
Capabilities
Section titled “Capabilities”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.
Errors
Section titled “Errors”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.
Registering a target
Section titled “Registering a target”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())?;Bundles
Section titled “Bundles”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.
The workflow
Section titled “The workflow”wre client bundleslists what is declared.wre client build --bundle default --signcross builds intodist/default/bin/<triple>.wre client listshows what the built binary carries.wre client describe exampleprints ops, events and capabilities.wre client package --bundle defaultwrites the four packages.wre client test --lang allruns the conformance suite through each binding.wre client publish --bundle defaultprints 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.tomlNode 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.
Conformance
Section titled “Conformance”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.
Diagnostics
Section titled “Diagnostics”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:
WRE_DIAG=always node app.jsThe 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:
wre client diag artifacts/clients/diagnostics/example-2026-08-15T17-13-25Z-s1.diag.jsonA 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.