Give your agent a precise handle on the web.
Wrench is an open-source, bring-your-own-agent CLI and TypeScript SDK. It is the capability and custody layer beneath any AI agent that can run a command: a way to capture pages, preserve media, query encrypted snapshots, and use reviewed account capabilities without handing the model a mouse, keyboard, cookie jar, arbitrary HTTP client, or every signed-in tab.
The caller asks for a named outcome such as messaging.list. Wrench binds that
operation to one exact provider, transport, account realm, contract version,
implementation, and risk level. If those facts drift, the operation stops. It
does not silently fall back to general browser control.
Bring the model, planner, tool loop, approval interface, and application shell you prefer. Wrench supplies precise web capabilities with local custody and explicit evidence.
wrench https://example.com/article
wrench capabilities
wrench plugin listProject site · Security policy · Plugin guide
- Capture knowledge. Turn a public URL into durable Markdown, inspect it without saving, and search the knowledge you keep locally.
- Preserve media. Archive one authorized, accessible, finite media item with source bytes, requested derivatives, transcript, manifest, and SHA-256 integrity records.
- Read connected services. Store validated account-bound reads as encrypted exact-query snapshots, then load the last verified state without reopening a browser or contacting the provider.
- Add one capability. Turn a reviewed first-party exchange into a typed, semantic operation with strict inputs, bounded outputs, and explicit trust.
- Intent over mechanism. Agents receive labeled operations, not credentials, selectors, scripts, caller-selected endpoints, or unrestricted browser access.
- Exact identity. Authenticated calls bind the provider, origin, transport, account, contract, and implementation instead of relying on ambient state.
- Visible drift. A changed origin, account proof, status, field, or response
shape returns to
capture-requiredrather than guessing or changing tools. - Local custody. Archives remain inspectable and exact provider snapshots remain encrypted. Verified cached reads can work without a provider roundtrip.
- Honest mutations. Consequential writes require an exact preview and durable dispatch evidence. An indeterminate write is reconciled and never blindly retried.
- Content-bound trust. Portable plugin approval applies to one verified content-addressed bundle, so changed code requires a new trust decision.
Wrench complements browser automation, direct API clients, MCP, and agent frameworks. Those tools own interfaces, transports, models, and planning. Wrench owns the narrow capability boundary that can sit beneath them.
Pin the public repository to the immutable v0.9.0 tag:
bun add --global github:hraness/wrench#v0.9.0
wrench adapter sync-bundled --json
wrench doctorWrench requires Bun 1.3.14. It runs on macOS and Linux. wrench doctor
reports capture, media, authentication, provider, plugin, and durable-recovery
readiness. Provider-specific commands remain unavailable until their exact
local dependency and auth contracts are ready.
wrench adapter sync-bundled atomically installs the reviewed data manifests
shipped by that exact package version. It upgrades only an exact current or
archived bundled baseline and preserves any independently modified install.
The public manifest projects each closure-attested package as an exact runtime dependency. Standalone validation installs without the repository lock, then verifies the resolved closure versions and reviewed entrypoint hashes.
Install Wrench in an agent or application that owns its own model, planning, tool loop, approvals, and interface:
bun add github:hraness/wrench#v0.9.0import {
isProviderPluginId,
isProviderPluginOperationName,
type ProviderPluginDefinitionV1,
} from "@hraness/wrench"
if (!isProviderPluginId(candidate.id)) {
throw new Error("invalid plugin ID")
}
const plugin = candidate satisfies ProviderPluginDefinitionV1
void pluginThe package root exposes programmatic plugin types and bounded validators.
@hraness/wrench/client exposes persistent-read client helpers, while
@hraness/wrench/omni exposes normalized cross-provider reads. Importing any
SDK entrypoint does not start the CLI. Importing the package root also does not
inspect local state or load provider runtimes.
wrench URL # capture into a Markdown knowledge base
wrench read URL # inspect without persistence
wrench archive URL # create a verified media archive
wrench audio URL
wrench video URL
wrench transcript URL
wrench verify path/to/archive-item
wrench context path/to/code # resolve nearby agent context
wrench search "query" # search the local knowledge base
wrench url-metadata backfill --root kb
wrench doctor --jsonWrench permits at most two locally owned browser acquisitions at once across
all Wrench processes that share the same state home. This first gate covers
fresh and profile-backed page capture. Explicit --cdp and --browser-live
attachments do not launch a Wrench-owned browser and therefore do not consume
a slot.
Admission is automatic. Polling uses bounded jitter and a budget equal to the lesser of the remaining capture timeout and 30 seconds. Queueing consumes the capture timeout. An in-flight bounded state-safety operation may settle after that polling budget expires, but Wrench rechecks the deadline and rolls back a late claim, so no browser launches after it. Each claim binds a random token to the owner's exact process-start identity. Wrench automatically reclaims a claim only after it verifies that the claim came from an earlier operating-system boot. A same-boot claim remains occupied even when its Wrench owner is dead because an owned agent-browser daemon or Chromium process may have survived. Malformed and unverifiable claims also remain occupied, so ambiguous state can reduce capacity but cannot raise it above two.
Initialize a brand-new state home once before starting several Wrench processes:
wrench runs list --jsonIf a crash leaves capacity blocked, run wrench doctor --json and read
wrench.home from the report. The admission files are under
<wrench.home>/captures/browser-admissions. Rebooting is the safest recovery;
the next capture can verify the prior-boot claim and retire it. Manual recovery
on the same boot requires first finding and terminating the exact orphaned
agent-browser and Chromium process group, then removing only its corresponding
slot-N.json. Never remove a claim merely because its Wrench PID is gone.
The slot remains held through upstream browser, proxy, process, and isolation cleanup settlement. Managed provider/bootstrap and derivation browser sessions remain outside this first gate and keep their existing containment and cleanup boundaries.
wrench url-metadata delegates to the shared @hraness/kb URL-intelligence
boundary. Backfill searches for bounded metadata through its pinned Rust search
helper, records resumable url-metadata.json sidecars beside saved URLs, and
performs read-only Archive.today discovery, including archive.is URLs, by
default. Pass --no-archive to disable archive discovery or --refresh to
replace an existing sidecar after a fresh bounded lookup. Run
wrench url-metadata --help for the complete limits and helper-path options.
Wrench archives one accessible, finite, non-DRM media item at a time. It rejects playlists, live streams, affirmative DRM, and unsupported authentication instead of weakening the archive boundary. Use it only for material you are authorized to access. Wrench does not bypass authentication, payment, access controls, or DRM.
Each completed media item retains the acquired encoded media,
privacy-projected provider metadata, requested derivatives and transcripts, a
versioned manifest, and SHA-256 integrity records. Inspect the directory
directly and run wrench verify to recompute every recorded artifact hash.
wrench capabilities --json
wrench capabilities x-web --json
wrench plugin list --json
wrench plugin show x-web --json
wrench platforms --json
wrench plugin doctor --jsoncapabilities reports the installed semantic operations and their current
contract state. A capture-required operation is an inert reservation, not a
partially supported request. Source plugins are trusted in-process code.
Portable plugins run as explicitly trusted child-process code; process
separation contains ordinary failures but is not a hostile-code sandbox.
Successful R1 invocations with a verified account subject publish an encrypted snapshot of the exact validated query and bounded provider output. The same query can be returned later without opening a browser or provider connection:
wrench auth bind reddit-main --site reddit-web
wrench reddit-web messaging.list --auth reddit-main --input '{"folder":"inbox","limit":25}' --json
wrench reddit-web messaging.list --auth reddit-main --input '{"folder":"inbox","limit":25}' --cache-only --jsonNormal invocation is the explicit revalidation step. Cache publication has a separate outcome from the live read, so a failed refresh or local publication never erases the last good snapshot. Inputs, account subjects, cursors, private IDs, and provider output remain inside authenticated local ciphertext. Replacing or removing an auth locator rotates its local lifetime identity, so old projection and provider-session ciphertext cannot revive after recreation.
UI clients can render the current snapshot before awaiting revalidation:
import { staleWhileRevalidateCapability } from "@hraness/wrench/client"
const messages = staleWhileRevalidateCapability({
adapterId: "reddit-web",
operationId: "messaging.list",
authId: "reddit-main",
input: { folder: "inbox", limit: 25 },
}, { freshForMs: 30_000 })
if (messages.cached?.status === "hit") {
render(messages.cached.output, messages.cached.freshness)
}
const refreshed = await messages.revalidation
if (refreshed.current?.source === "cache") {
render(refreshed.current.output, refreshed.current.freshness)
} else if (refreshed.current?.source === "live") {
render(refreshed.current.output)
}current applies Wrench's ordering policy. It prefers the verified
cachedAfter snapshot after a failed refresh, a superseded publication, or a
cache error with a concurrently advanced run, revision, or validation time. It
uses live output only when that output is still current, and is null when a
failed refresh has no last-good snapshot. cachedBefore, cachedAfter, live,
and cache remain available for diagnostics and richer UI states.
Exact snapshots preserve provider page and completeness semantics without
reinterpretation. Revalidation reruns the selected R1 operation; it does not
imply a separate provider sync. In particular, WhatsApp reads revalidate its
local linked-device projection, while wrench auth sync <id> --once remains
explicit.
contacts.list uses one shared directional-statistics shape. A count is either
complete, an explicit lower bound, or null when the provider cannot supply
it. Timestamps carry the same completeness and basis evidence. Providers do
not turn missing message history into zero activity.
| Provider | Contact collection | Directional statistics |
|---|---|---|
| Gmail | Google People connections | Bounded Gmail message scans with explicit truncation |
| LinkedIn official API | First-degree connections with locale-selection evidence | Unavailable; the Connections API does not expose ordinary inbox history |
| Instagram authenticated web | Unique non-viewer participants from the reviewed first Direct inbox summary page, with explicit first-page and pagination incompleteness | Unavailable until acknowledgement-free message-history paging is reviewed |
| WhatsApp linked device | One page of the authenticated account owner's private, quiescent Whatsmeow contact store | Unavailable; Wrench does not treat a linked-device message cache as account-owned history |
| Facebook authenticated web | Capture-required reservation for friends or Messenger participants | Capture-required |
| Telegram | Not installed | Requires a reviewed TDLib user-session lifecycle; Wrench does not substitute the Bot API or claim contact access |
LinkedIn requires approved access to both the restricted
r_1st_connections and r_liteprofile scopes. Before listing connections,
Wrench reads /v2/me, derives the exact authenticated person URN, and compares
it byte-for-byte with the OAuth locator. Its consumer-web contact operation
remains capture-required and never falls back from the official API:
wrench linkedin contacts.list --auth linkedin-main \
--input '{"start":0,"count":25}' --jsonInstagram returns only participants visible in one reviewed first inbox page.
Its output marks provider pagination signals and local thread or contact limits
as incomplete instead of presenting that page as a complete contact set.
WhatsApp reads contacts from the authenticated account owner's private,
quiescent Whatsmeow session.db without opening a new WhatsApp connection.
Message counts and last-message timestamps remain explicitly unavailable:
wrench instagram-web contacts.list --auth instagram-main \
--input '{"thread_limit":25,"contact_limit":50}' --json
wrench whatsapp-web contacts.list --auth whatsapp-main \
--input '{"limit":50}' --jsonTelegram's official getContacts method belongs to
TDLib's user-client API.
Wrench will not install or expose this surface until it can bind the TDLib
authorization lifecycle, account identity, local database, paging behavior,
and message-history completeness without weakening the linked-device boundary.
Gmail uses the official Gmail and People APIs. Create a current-user-owned mode-0600 token document whose provider, subject, and sorted scopes exactly match its Wrench auth locator:
{
"schemaVersion": 1,
"provider": "gmail",
"subject": "[email protected]",
"scopes": [
"https://www.googleapis.com/auth/contacts.readonly",
"https://www.googleapis.com/auth/gmail.readonly"
],
"accessToken": "replace-with-the-access-token",
"expiresAt": "2099-01-01T00:00:00.000Z"
}wrench auth add gmail-main --oauth-provider gmail \
--token-file /absolute/private/gmail-token.json \
--scopes https://www.googleapis.com/auth/contacts.readonly,https://www.googleapis.com/auth/gmail.readonly \
--subject [email protected]
wrench gmail contacts.list --auth gmail-main \
--input '{"limit":20,"stats_scan_limit":100}' --json
wrench gmail messaging.list --auth gmail-main \
--input '{"view":"inbox","limit":25}' --json
wrench gmail messaging.list --auth gmail-main \
--input '{"view":"search","query":"from:example.com has:attachment","limit":25}' --json
wrench gmail messaging.read --auth gmail-main \
--input '{"thread_id":"thread-id-from-list"}' --jsonContact statistics report sent and received counts plus the maximum internal
date across every bounded matched message. Count and date completeness flags
remain explicit when the scan bound truncates a query or a message lacks a
date. Contacts with mixed, unsupported, or absent addresses report partial,
unsupported, or unavailable address coverage and lower-bound, incomplete
statistics instead of exact zeroes for unscanned mailboxes. limit * stats_scan_limit cannot
exceed 2,000, which bounds the per-direction Gmail scan before its paired
metadata reads. Inbox and search rows include a provider-derived threadUrl
and the exact messaging.read input. Reading does not mark a message seen or
emit a protocol acknowledgement.
Pass a returned Gmail threadUrl to wrench read or wrench clip with the
same auth locator. Gmail clips default to private Wrench state rather than the
Git-backed knowledge base. --output <directory> is the explicit plaintext
export boundary. Attachments are content-addressed and integrity-recorded;
the implicit capture default and explicit --media all include every MIME
attachment, while --media none omits their bytes. --media images is rejected
because it would misrepresent non-image files in a Gmail thread. The private
bundle keeps one physical file per digest and a gmail.json occurrence map for
message, MIME part, provider attachment, declared filename and MIME type, and
snapshot provenance. Its schema-2 provenance preserves normalized reviewed headers
for each message: Subject, In-Reply-To, From, To, Cc, Bcc, Date, and Message-ID. Every
physical attachment object uses the deterministic <sha256>.bin name and
application/octet-stream manifest type, so conflicting or active declared
types cannot create a second object or activate stored content.
Text body leaves that Gmail externalizes through its attachment endpoint are
resolved within the same body budget before a read or clip reports completion.
Official messaging.read results cap full-thread decoded text at 7 MiB. Gmail's
Omni projection keeps an exact UTF-8-safe 256 KiB prefix per message and sets
bodyTruncated explicitly without adding a synthetic marker.
Full-thread JSON reserves at most 32 MiB for inline attachment payloads;
provider-hosted attachment endpoints remain independently bounded to 100 MiB
per file, so profile, thread, and attachment responses never share one broad
memory allowance.
Wrench separates private draft saving from publication:
articles.draft.saveis R2. It creates or replaces one private native draft and has no publish-capable branch.articles.publishis R3. It is a different semantic operation with its own installed contract, preview, confirmation, and exact result binding.
A draft ID is not permission to publish, and a draft preview cannot be reused for publication. The separate official API and signed-in web adapters remain distinct transports and auth realms; Wrench never switches between them to fill a capability gap.
The current provider state is explicit:
| Adapter | articles.draft.save |
articles.publish |
|---|---|---|
x |
Observed R2 response-bound private draft create through the documented OAuth API | Observed R3 response-bound publication through the documented OAuth API |
x-web |
Observed R2 private draft save | Capture-required R3 |
linkedin-web |
Observed R2 paragraphs/headings/native-link private draft save with exact unpublished editor-response readback | Capture-required R3 |
The x-web draft operation accepts a title, a canonical provider-neutral
ArticleDraftDocument schemaVersion 1 string, and an optional exact existing
private draft_id. The document supports paragraphs, headings, blockquotes,
list items, bold/italic/strikethrough ranges, and native canonical HTTPS link
ranges. It does not accept Markdown, HTML, embeds, image blocks, covers, or
files. The separate official x OAuth operation exposes only its reviewed
plain-text body contract plus an optional cover. Inspect the exact installed
capability instead of translating inputs or switching transports implicitly.
Capture or read source material separately. The caller owns every editorial choice involved in translating, abridging, retitling, attributing, and linking it for the destination. Wrench sends only the final reviewed title and document; it does not turn a source URL into provider copy.
For X, put the exact inner canonical JSON document in a private input file:
{
"title": "Reviewed title",
"document": "{\"blocks\":[{\"links\":[{\"length\":6,\"offset\":9,\"url\":\"https://example.com/source\"}],\"text\":\"Read the source\",\"type\":\"paragraph\"}],\"schemaVersion\":1}"
}Then use one account-bound signed-in realm:
wrench adapter sync-bundled --json
wrench auth add x-main --cookie-source arc
wrench auth bind x-main --site x
wrench capabilities x-web --json
wrench x-web articles.draft.save \
--input @/absolute/private/article-draft-input.json \
--auth x-main --preview --json
wrench confirm <preview-digest> --jsonReview the exact account, title, canonical document, optional draft ID,
contract, and dispatch schedule. Require a successful result to identify
articles.draft.save, report published: false and mode: "draft", and return
the private draft identity. Do not retry a partial or indeterminate save and do
not call articles.publish as recovery. Signed-in X replacements with an exact
input draft ID can use read-only reconciliation; an indeterminate create cannot
be safely targeted and must remain unsettled.
Signed-in LinkedIn now exposes the same private R2 seam through
linkedin-web articles.draft.save. The observed contract supports paragraphs,
H1/H2 headings, and native HTTPS links; it creates or replaces only one bound
private draft and independently verifies the exact unpublished result from one
bounded hidden server payload in the authenticated editor HTML. Its fixed
first-party writes and server-response read run inside a contained, account-bound Chrome
session because LinkedIn rejects the same editor traffic when replayed by a
standalone HTTP client. Wrench does not type into or inspect the editor DOM,
and the contained headed browser may be visible while the private save runs.
Covers, inline media, lists, blockquotes, styles, and publication remain unavailable.
See the packaged native article draft workflow
for the shared document grammar and safety sequence.
The omni layer materializes selected exact inbox snapshots into a strict shared union of conversations, messages, and notifications. Each provider owns a pure, versioned materializer with explicit identity, pagination, completeness, tombstone, and deletion semantics. Unsupported providers say why. A shape change fails at that provider-owned boundary, retains the last good normalized entities, and records the exact failed revision instead of guessing.
Omni v1 has no provider-authored write-invalidation tags. Auth-incarnation,
materializer, and plugin implementation identity changes strand the prior
normalized coordinates. Freshness advances only when the exact query is
explicitly revalidated. If a newer exact snapshot drifts, Wrench keeps the last
good derivative and reports retained-after-drift. The provider-local
diagnostic remains inside encrypted normalized state. Public reasons are
categorical and do not echo foreign values or unreviewed property names.
wrench omni read --input '{
"schemaVersion": 1,
"sources": [
{"adapterId":"reddit-web","operationId":"messaging.list","authId":"reddit-main","input":{"folder":"inbox","limit":25}},
{"adapterId":"whatsapp-web","operationId":"messaging.list","authId":"whatsapp-main","input":{"folder":"all","limit":100}}
],
"filter": {"kinds":["conversation","message","notification"]},
"page": {"limit":100}
}' --cache-only --json--cache-only reads encrypted normalized state without a browser or provider
round trip. --from-exact-cache rebuilds derivatives from encrypted exact
snapshots. The default mode revalidates supported sources independently and
then returns one locally paged view. Provider cursors remain private; public
view cursors are authenticated and bound to the request, account lifetimes,
materializer closure, and view revision.
Each source row exposes a keyed normalizationDataRevision for causal cache
comparison without revealing normalized bytes. During SWR, current may be an
omni-merged result: it adopts a proven newer cached view while retaining every
unresolved live source status. A concurrent advance for one provider, account,
or continuation therefore cannot erase another live failure; the independent
live and cachedAfter observations remain available as well.
import { staleWhileRevalidateOmniView } from "@hraness/wrench/omni"
const messages = staleWhileRevalidateOmniView({
schemaVersion: 1,
sources: [{
adapterId: "reddit-web",
operationId: "messaging.list",
authId: "reddit-main",
input: { folder: "inbox", limit: 25 },
}],
})
render(messages.cached?.view)
render((await messages.revalidation).current.view)An agent can create a private, network-inert starting point without editing Wrench:
wrench plugin init example-web \
--display-name "Example" \
--surface example \
--origin https://www.example.com \
--operation feeds.read \
--output /absolute/private/example-web
wrench plugin check /absolute/private/example-web --json
wrench plugin test /absolute/private/example-web --trust-code --json
wrench plugin pack /absolute/private/example-web \
--output /absolute/private/example-web.wrenchplugin --json
wrench plugin install /absolute/private/example-web.wrenchplugin \
--trust-code --jsoninit writes a strict wrench-plugin.json, a self-contained runtime, inert
operation metadata, secret-free fixtures, and package-local agent guidance.
check is static and does not execute plugin code. test --trust-code binds
the decision to the verified plugin identity before running its declared
secret-free fixtures. pack creates a reproducible content-addressed package.
install --trust-code is the separate decision to let that exact package run.
Portable code receives only declared, bounded host capabilities. Network requests are pinned to declared HTTPS origins; credentials are opaque handles usable only at declared sinks; files and state are namespaced handles; and mutations must use the kernel's begin, request, and verify sequence. The host does not expose a shell, package manager, ambient environment, unrestricted filesystem, redirect, retry, or arbitrary request primitive.
Read the plugin guide before replacing an inert reservation with an observed contract. The packaged Wrench Agent Skill gives coding agents the same workflow and safety boundary.
- R1 is a reviewed read with no intended remote mutation.
- R2 is one bounded, normally reversible change.
- R3 is an externally visible or consequential change.
- R4 is blocked.
R2 and R3 commands create an exact, short-lived preview. Review its adapter,
transport, account realm, input, attachment hashes, side effect, contract hash,
and complete dispatch schedule, then pass its digest to wrench confirm.
After a partial or indeterminate dispatch, Wrench does not retry or switch
transport. The run remains unsettled until exact external evidence supports a
separate reconciliation.
New previews use one environment-neutral durable contract identity. Readers
also accept the exact predecessor identities produced by the standard test,
production, and development modes. They do not accept a wildcard identity
for custom NODE_ENV values. Wrench retains unsupported unsettled evidence and
directs the operator to wrench doctor, the exact predecessor build, or manual
evidence review. Runtime loading still verifies the current exact source,
dependency, and execution closure separately.
git clone https://github.com/hraness/wrench.git
cd wrench
bun install --frozen-lockfile
bun run checkThe full gate type-checks, tests, builds, runs the secret-free CLI and portable plugin lifecycle smoke, then installs and imports the packed package in a clean consumer. See CONTRIBUTING.md for change boundaries.
MIT
