@wrenautomation/autobrowse
v0.4.0
Published
Browser and account automation: recorded flows where no API exists, official API shapes where one does, a person in the loop for money, accounts and consent.
Readme
autobrowse
Browser and account automation for the fleet: domains, Workspace, inboxes,
logins, tokens. Recorded browser flows where no API exists, APIs where one
does, a person in the loop where money, accounts or consent are involved.
Separate from wren (the campaign system) on purpose: different
credentials, different runtime (browser sessions, waits for a human),
different release pace.
On npm as @wrenautomation/autobrowse: npx @wrenautomation/autobrowse --help
for the CLI, import … from "@wrenautomation/autobrowse/sites" (also /auth,
/do, /agent, /flows, /llm, /layout) for the library. /layout is page
text laid out as it looks (layoutText(page, limit) on a Playwright page; layout(boxes) is pure). A v* tag publishes
(.github/workflows/release.yml, trusted publishing).
New here: walkthrough/ is the guided path (setup → secrets → sites →
Meta → Google → explore → agent → accounts → chores → the box) with demos
that run now.
One verb
pnpm autobrowse do "upload this to youtube" --input file=talk.mp4 --input title="Talk"
pnpm autobrowse do "list my linkedin posts"
pnpm autobrowse do "rename my google account" --input name=Wren --site google --dry-run
pnpm autobrowse do "deploy this on cloudflare workers" --input dir=./worker
pnpm autobrowse abilities # what `do` can pick from, and what is not recorded yetdo routes a goal to what does it: a site route under its official API
shape, a compiled workflow, a hand-written flow, a command-line tool
(wrangler-deploy, gh-pr-create, ffmpeg-convert; ready when the binary
is on the PATH, src/do/tools.ts). What the model picked for earlier goals
is kept (recordings/.do-picks.json) and shown to it, so the same ask in
other words lands on the same ability. With nothing ready, the
agent explores the site once; what it achieved is saved and compiled,
under the missing leg's name when a route was waiting on one, so the second
same ask runs deterministically. Gates hold inside every leg. The same verb
is POST /api/do (a job; dryRun answers at once), the Restate service
do/run for wren (restateDo in @wren/core/content), and the do tool
of the MCP server. src/do/.
Hands off
Nothing waits for a person unless a guard says so. Login walls are solved
with stored credentials, TOTP generated in-process, and one-time codes
read from an inbox we control. Guards (GUARDS) are the situations a
person still approves: a purchase, a password reset, a locator miss on an
irreversible act. Each is on by default and can be switched off. A send
gate (a step that files, posts or sends something in your name; every
compiled irreversible step opens one) is always asked: approve <workflow>
<key> send. What stays human: hardware keys, adding a payment method,
a captcha the solver cannot read (src/browser/captcha: checkboxes by a
human click, squares, letters and sliders by the configured model when it
sees; CAPTCHA_ATTEMPTS tries, default 3, before it is yours).
The bar for asking. Try it first. NEEDS-WILLIAM.md and the needs
rows are only for what a machine cannot hold: your password or passkey,
your money, your taste, your name on a decision — or something autobrowse
tried and failed at repeatedly, with the failures written down. Anything
else, autobrowse does: a flow it has not recorded is a flow to record, a
site with no leg is a leg to build. "Someone should set this up" is never
a row; it is work.
How it works
- Durable. Every flow is a Restate Virtual Object. Each step is one
short invocation that sends itself the next, so the object is never
busy for longer than one step:
pause,reset,approvealways get in. - Gated. A gate is state, not a parked invocation. The step that needs
an answer stops the run;
approve/rejectrecord the answer and send the next step. Restate keeps the state for as long as it takes. - Hand-offs. A browser flow that meets a login, captcha or consent
throws
NeedsHuman. The runner saves a screenshot and a Playwright trace, the run waits at gatehuman, the person does the thing in the persistent profile and approves; the step reruns. The worker keeps the browser open across the wait (BROWSER_KEEP_MINUTES, default 10; at mostBROWSER_KEEP_MAX, default 2), so the rerun starts on the same page. - Verified. A browser step is proved by an API read afterwards (the purchase by the Registrar API, DKIM by the record's shape, the user by Directory). The trace is for the person; the API read is for the machine.
- Read. A flow can keep text as well as act:
read {ref, as}in the agent,readin explore mode,fp.read(hints)in a compiled step. The values come back as the step's result, so a workflow scrapes the same way it clicks, with no model at run time. - Recorded.
autobrowse record <name>opens a headed browser with an observer: clicks, typing, navigations, each with a screenshot and locator hints (role, label, text; never CSS). Typed secrets are redacted at capture.ppauses (nothing captured),qfinishes, any other line is a note.--terminalrecords the shell leg after. Raw recordings stay out of git. - Explored.
autobrowse agent <site> "<goal>"finds the way itself: each step the model sees the URL and an outline of the page ([n]B Saverefs, containers kept, rows one line, repeats folded; ~1k chars for a GitHub issues page), picks one act, the code runs it and journals it.doneorhumanends it; the journal is a recording,compilemakes it a deterministic flow the worker serves at once (oneCompiledobject keyed<workflow>/<key>, loaded from disk per run, so no restart);autobrowse try <name>runs it in-process. Explore by agent once, then run the flow forever.pause/resumeover loopback lets a person step in mid-run: while paused the browser is theirs and every click and keystroke lands in the same journal; the agent re-reads the page when it resumes. The UI's Explore page is the same thing with a form, live steps and screenshots, pause/resume/stop, save, compile. The journal is written as it grows: a session that dies (crash, restart) resumes on its last page with every act, and a dead agent session's resume goes on from its last step, not step 1.--freshstarts over. - Self-repairing. Every flow failure writes
<stamp>.failure.json(site, URL, last goal, error).autobrowse repair <that file>starts the agent on that page toward the flow's goal and records the way through; compile it, splice it in. The loop: run → fail → explore → compile → run. - Self-building. The evaluator (
/api/agent/proposals, Explore page) reads flow failures, agent sessions and recordings and says which recurring needs deserve a workflow, each as a site + goal one click from an agent session;EVALUATE_EVERY_HOURS=6runs it on a clock and messages the fresh ones.AUTO_BUILD=truegoes the last step alone: a fresh proposal seen twice or more is explored by the agent, saved, compiled, proven (one run on its own,proof.jsonbeside the flow, shown on the Runs page), and you hear what got built or where it needs you.autobrowse try <name> --proveor "prove" on the Runs page writes the same proof by hand.LLM_PROVIDER=claude-coderuns every model call through headless Claude Code on your subscription, no API key (designs/2026-09-21-claude-code-driver.md);LLM_PROVIDER=cohere(COHERE_API_KEY) goes through Cohere's own v2 door, which is the only one that keeps a thinking model's answer apart from its reasoning. Every model call counts againstLLM_DAILY_TOKENS(3M a day by default, one ledger for worker and CLI); over it, calls fail loudly until UTC midnight, so an unattended night cannot run up a bill. The agent'shumanis a pause with a prompt: do the captcha or the purchase in the window, resume, it goes on. - Accounts. A site name may carry an account:
google@opsis the google walk with credential and browser profilegoogle@ops. One profile per identity, so two accounts never meet in a chooser. - Explored.
autobrowse explore <site> [--url u]: one hidden browser stays open on the site and takes commands over loopback, one at a time (open,click,fill,aria,eval,pages/pagefor an OAuth popup,save,close).ariadumps the page's accessibility tree: every control by role and name, so a whole form is mapped in one look instead of one miss per run. Every act that works is journaled as a recording;savewrites it,compiletakes it from there. Failing flows leave the same aria tree next to the screenshot (<stamp>.aria.txt). Each session is also a run, kept for good:goalnames it,doneends it, and runs that reached the same goal build a walk (walks build), a flow with no model in the loop.teachmakes one from a chore you do once by hand: what you typed becomes a profile value, a secret, a date relative to the day, or a field with your text as its default, and you confirm each guess. - Minted secrets.
{"cmd":"keep","hints":…,"env":"X_API_KEY"}reads a key the site just showed straight into the secret sink (.envlocally, SSM in prod) under that name. The journal keeps the element and the env name, never the value; a compiledkeepop does the same throughdeps.sink, outside the run's journal. The agent haskeep{ref,env}. - Placed secrets.
{"cmd":"place","hints":…,"secret":"password"}fills a field with a value the session holds by name and the socket never carries: the agent hasplace{ref,secret}and is told which names exist, never their values.pnpm autobrowse signup instagram --email [email protected] --name "Wren Automation" --handle wrenautomation --headedmints a 24-char password, stores the credential sealed underinstagrambefore the browser opens, and lets the agent make the account placingemail,password,code(read from that inbox or the phone) andphone; a captcha the solver cannot read hands off to you in the window (enter to go on).--inbox will@…reads the codes there when the address is an alias. The journal keeps every placed field redacted, so the compiled flow reads it as a secret by key.place{secret:"profile.taxId"}fills a field from the Mac's wallet profile (name,email,phone, an address field;profile@<id>.…picks one). It is the person's own, not a site's login, so any host may have it. A billing email, phone or tax id field is set to the card owner's on its own when a card lands and before a spend click. - Env store. Secrets travel through SSM Parameter Store, one
SecureString per name under
/autobrowse/config(KMS at rest, IAM at the door, every read in CloudTrail; no extra vendor).autobrowse env push TWILIO_ACCOUNT_SID TWILIO_AUTH_TOKENsends local.envkeys up;env push --from deploy/prod.envsends a whole file;env set NAME --clipboardstores a new one straight from the clipboard; the box reads the store on every deploy. Back down on any machine with AWS access:env get NAMEputs one value on the clipboard for a minute (Universal Clipboard carries it to a phone),env pullmerges all of them into a 0600.env,eval "$(autobrowse env pull --export)"loads a shell.env lsprints names only; nothing prints a value unless--print. - Claude Code. The skill in
.claude/skills/autobrowse/is a router (SKILL.md: the job table, the standing rules, live state) and one file per job: research, leads, site APIs, explore, signup, accounts. The explore session isscripts/start.sh <site> [url], thenscripts/cmd.sh <port> '{"cmd":…}',save,stop.sh. It costs context only when invoked; an MCP server's tool schemas would sit in every session.autobrowse mcpstill exists for clients that want tools (claude mcp add autobrowse -- pnpm autobrowse mcp), off by default. Same journal, same redaction, same compile.LLM_PROVIDER=claude-codeis the other direction: Claude Code as the model behind the built-in agent.exploreleaves its bearer token in$TMPDIR/autobrowse/explore-<port>.token(owner-only) for the session's life, never in its output. - Origin binding. A password (or a placed signup secret) only types on
a host under its site's domains (
src/auth/guard.ts): the site's home, itsorigins, the credential's own URL. Any other host — a redirect, a look-alike, a model told to paste it elsewhere — throwsSecretLeakbefore a keystroke. A compiled workflow's secrets (deps.secrets) are bound the same way to the flow's own site at run time. Every use, allowed or refused, is one line in~/.config/autobrowse/audit.jsonl(never the value);pnpm autobrowse creds audit --last 50reads it.creds canary stripeplants a tripwire credential: any read of it is a refused line in that ledger and a note to you. The ledgers (audit, spend, agent steps) are hash-chained (credvault'schain: each row carries the hash of the one before):pnpm autobrowse ledger verifyfinds an edited, dropped or inserted row. Tamper-evident, not tamper-proof; that is what the file mode and the box's IAM are for. - Payment gate. A billing field or a button that spends (
src/gates/) is never the session's own call: the act waits on a yes from the person over a channel they answer on (phone, Linq, email), and is refused outright when no such channel is set. Compiled steps that touch billing are gated the same way as irreversible ones; the agent stops on a no. The ask carries the amount on the button ("Buy $20 of credits" → 20.00 USD), else the order total next to it (the last "total"/"amount due" line in the block around the button; never a subtotal).SPEND_ALLOW=anthropic SPEND_AUTO_YES_UNDER=25 SPEND_DAILY_CAP=50 SPEND_HARD_CAP=500lets the gate say yes alone to a small purchase on a named site while the day's total is under the cap, and refuse anything over the ceiling before anyone is asked; a button with no amount is always a question. Every decision is a line in~/.config/autobrowse/spend.jsonl;pnpm autobrowse spendreads it. - The ledger comes to you. Both files live on the box's disk, so when
the box stops itself for idleness it first sends the session's summary
over the channel (secret uses with every refusal, every gate decision
with its amount; nothing when nothing happened).
GET /api/ledger?since=serves the same window to the UI/CLI. - Desktop. The same session takes
{"cmd":"os","act":{…}}: apps, the front app's controls as a tree (tree, likearia),clickby role and name,type,key("cmd+shift+4", "return"),shot, andshellwithroot:truefor a command that needs it. Desktop acts are journaled beside browser acts and compile to adesktopstep that replays throughdeps.desktop; typed passwords (secret:true) never enter the record. Root goes through one audited helper (autobrowse desktop setupprints the three commands that install it; every root command lands in/var/log/autobrowse-root.log). macOS grants Accessibility to the app running node once, by hand;desktop setupsays whether it has. - Devices.
PHONE_NUMBERlinks your own phone through the Mac it is paired with: SMS codes are read from Messages, "tap Yes" nudges go back over iMessage. Twilio is the rented-number twin.setupchecks the two one-time macOS switches and opens the pane. - Compiled.
autobrowse compile <name>: recording →outline.json(steps at notes and navigations, typed inputs vs secrets, irreversible verbs, pauses → hand-offs) → a workflow module plus a test that typechecks against the library. A model may polish names and proofs; it can never change what runs. Edit the outline (the Workflow page in the UI, or the file) and it re-renders;--from-outlinedoes the same from the CLI. - Finished.
compile <name> --finishorfinish <name>: a model does the last mile a person did by hand (plan inputs for what the recording hard-coded, fill and submit as two steps with asendgate between, a proof read, a gate test), judged by tsc and the workflow's own test, three rounds, originals restored on a give-up. A heal runs it after the re-render, before the proof.designs/2026-09-22-self-finishing-compile.md. - Repaired. Generated flows act through
fp.act(op, hints, {goal}). A stale locator asks the repairer (a model, later Stagehand) for new hints for the same goal, tries once, reports the repair. Irreversible ops are never repaired; they hand off. - Screens. Sites branch on state (a remembered account, a first-visit
banner, a new device), so a page is known by its URL and landmarks and has
one plain-code handler (
browser/screens). A step whose control is not there gets the interrupts handled first: a cookie banner, and any screen a repair once clicked past on that site (kept per site, so every flow on it gets it). A flow whose path branches (Google's and Cloudflare's sign-ins) is a walk: its screens and a goal, no script; look, act, look again. A page no screen knows goes down one ladder: learned screens (data) → a model that only picks from the walk's own list, or one non-committing click → a person. The pick is kept once it worked, so it costs a model once per new variant.autobrowse screens [site]lists what was learned (SCREENS_FILE);--forgetonce the source knows the screen.autobrowse login --allsigns in to every stored account; run it before a batch that must not stop. - Play/pause.
pauseholds before the next step;playruns on. A dry run stops before the first irreversible step. - Watched. The worker serves a UI on
:9080: runs, the open gate with its screenshot, controls, live events, recordings and compile./hooks/inboundtakes what a person typed on any channel. The header'sheaded/headlessbutton is the one live setting (GET/PUT /api/settings {headless}): every browser opened from then on follows it;BROWSER_HEADLESSis only its value at boot.explore,agent,repairandlogintake--headedwhen you want to watch one;recordis always headed. - Where the browser is.
BROWSER=local(a Chrome per site profile),browserbase, orcdpwithBROWSER_CDP_URL=http://127.0.0.1:9222: a browser already running, or an Electron app (new Outlook, Slack, Notion) started with--remote-debugging-port=9222; its pages are the site, the same flows and agent drive them, and nothing of it is closed on the way out. The desktop leg for apps that are web pages inside. - Your own browser, per site.
OWN_BROWSER_SITES=github(andOWN_BROWSER:chromeby default, oropera-gx,brave,edge, a data dir): those sites run in a new tab of the browser you use, signed in, with the history a site trusts. Once: openchrome://inspect/#remote-debuggingand turn on "Allow remote debugging"; the browser asks you to allow each connection. Nothing restarts, only our tab closes, and no trace or passkey stand-in touches your other tabs. For sites that refuse a fresh profile (GitHub's Google sign-in). Safari can't be attached (designs/2026-09-27-own-browser.md). - One account, several ways in. A credential can hold a password and a
provider (
creds setthencreds via): the password goes first, the provider if it is refused.creds same github github@xfolds two entries into one;creds rename <from> <to>moves a name and its browser profile. - Accounts by who and what for. Each account has a username and roles
(
main,alt,wren).x,x@wrenandx@wren_automationall reach the same one.creds listshows username, roles, sign-in.creds role x wren <account>moves a role (designs/2026-10-02-account-roles.md). - Agent access.
autobrowse access grant <agent> --sites <site,site@account> --workflows <names> --can doprints the agent's key once. An agent that sends it as its bearer sees and calls only those sites, accounts, workflows and tools. Gates, setup, accounts, wallet and settings stay the owner's (UI_TOKEN).access list,access revoke. Stored hashed inACCESS_FILE(designs/2026-09-27-agent-access.md). - HTTP API. Errors are
{ error, code, issues? }:errorfor a person,codefor a program (invalid_query,not_found,rate_limited, …). Lists stay arrays, newest first; more pages come asLink: <…>; rel="next".GET /api/runs?status=waiting,running&workflow=domain&limit=50filters in the registry. Each caller gets 600 reads and 120 writes a minute, bodies up to 1 MB (designs/2026-10-03-api-conventions.md).
Flows built
- Domain (
designs/2026-09-19-domain-flow.md): check → buy → zone → Workspace → verify → mail DNS → DKIM → inboxes → signatures → authenticator → photo → warmup (Instantly API) → roster → loops. Gated at the purchase. Each inbox is credential + profilegoogle@<email>. - Workspace inbox (
designs/2026-10-03-workspace-inbox.md): one inbox on a domain already in Workspace. User, password and authenticator stored, signature, photo, Gmail consent token, accounts row. Warmup, roster and newsletters are opt-in. Reuses the domain and inbox-activity steps. - Inbox fleet (
designs/2026-10-03-inbox-fleet.md): cold-email domains bought at Dynadot, three mailboxes each from Inbox Insiders (full control, sent to Instantly), each domain on its own Route 53 name servers, its site masked by CloudFront, then warming. One purchase yes covers domains and mailboxes. - Hand-written legs (
src/browser/flows/, callable as the Restatebrowserservice'sflow):google-admin/dkim-*,google-admin/workspace-logo,google/profile-photo,google/oauth-consent,linkedin/oauth-consent,instagram/oauth-consent,tiktok/oauth-consent,outlook/oauth-consent,google/youtube-community-post. - Compiled from recordings (
src/workflows/):bootstrap(mints the first Cloudflare token),google-cloud-project,google-cloud-oauth-client,anthropic-console-api-key,workspace-skip-passwords,google-name,aws-port25-request(EC2 email-limit removal form; the submit is a gate). - Logins (
src/auth/sites.ts): cloudflare, google, google-admin, instantly, aws, anthropic, twilio, sentry, linkedin, instagram, tiktok, outlook (themicrosoftcredential), discord (hCaptcha by the runner, 2FA from the stored key); providers google, github, microsoft behind any "Continue with …" button.
Run
cp .env.example .env # fill it
pnpm ui:build # the SPA the worker serves
pnpm worker # Restate endpoint on :9081, UI + API on :9080
restate cloud env tunnel # expose it to the shared Restate Cloud env, register it
pnpm ui:dev # SPA with hot reload on :5173, proxied to :9080
pnpm autobrowse setup # asks once for what is missing (hidden input, sealed store)
pnpm autobrowse login cloudflare # signs in by itself: password or the Google button, TOTP/email/SMS code
pnpm autobrowse workspace-logo logo.png # the org logo across Gmail/Calendar/Drive (320×132 PNG < 30 KB)
pnpm autobrowse profile-photo pfp.gif --as google@wren # an account's round picture in Gmail; a GIF stays animated
pnpm autobrowse enroll-totp cloudflare --url https://dash.cloudflare.com/profile/authentication # reads the seed, stores it, confirms
pnpm autobrowse enroll-totp [email protected] [email protected] # several Google accounts, one after another
pnpm autobrowse creds to-passwords [email protected] # login + authenticator into Apple Passwords, sorted (all your devices)
pnpm autobrowse inbox-name [email protected] will jin # fix a Workspace inbox's display name (admin API)
pnpm autobrowse inbox-photo [email protected] pfp.png # a Workspace inbox's picture, no sign-in (admin API; still, 96 px)
pnpm autobrowse run bootstrap cloudflare --plan '{"provider":"cloudflare"}' # mints CLOUDFLARE_ACCOUNT_ID + API token into .env (--plan: JSON, a file, or -)
# map a page by hand or by model: one open browser, one command at a time (token printed at start)
pnpm autobrowse explore cloudflare --url https://dash.cloudflare.com/profile/api-tokens
curl -s -X POST -H "Authorization: Bearer $TOKEN" http://127.0.0.1:9090/ -d '{"cmd":"aria","hints":{"css":"main"}}'
pnpm autobrowse agent google "open Personal info and report the display name" --save google-name # model explores, journal → recording
pnpm autobrowse repair ~/.config/autobrowse/artifacts/google-x-2026-….failure.json # agent picks up where a flow stopped
pnpm autobrowse creds paste google@ops # a second account: `email password [key]` on the clipboard (or the UI's Accounts page)
pnpm autobrowse accounts add [email protected] --for pays # which of your accounts is for what (pays, default, signup); consents, signups, `via` read it
pnpm autobrowse accounts add [email protected] [email protected] --for sends # any other word is a group: many accounts share it
pnpm autobrowse accounts list --for sends # one group, with each account's credential and inbox
pnpm autobrowse creds rm [email protected] # forget a credential (an address = its Google account); creds restore brings it back
pnpm autobrowse accounts # each account's purposes and readiness (credential, inbox, tokens); names only
pnpm autobrowse needs # what only you can give (logins, keys, consents, phone, money, decisions), each with its check + command
# (the UI has the same list on its Needs page, and the account policy on Accounts)
pnpm autobrowse needs do login-linkedin # runs the row's ingestion (clipboard creds, a consent, a setup step); `needs done <id>` for decisions
pnpm autobrowse creds copy google # one stored field onto the clipboard for a minute (--field password|username|totp|recovery|previous); never printed
pnpm autobrowse creds password google # you changed it on the site: type it here twice, echo off; only the store is touched
pnpm autobrowse creds rotate google --ask # change it on the site itself, to one you type here (no --ask: a random 24-char one)
pnpm autobrowse creds push linkedin # that stored credential into SSM as AUTOBROWSE_CRED_LINKEDIN_*: the box signs in too
pnpm autobrowse creds push --all # every stored site (canaries never travel)
pnpm autobrowse creds pull [sites...] # the other way, on a second laptop; --overwrite to replace what is here
pnpm autobrowse signup instagram --email [email protected] --name "Wren Automation" --handle wrenautomation --headed
pnpm autobrowse inbox-accounts [email protected] # the free accounts in src/auth/free-accounts.ts for one inbox (email signups + Google logins); skips what it holds
pnpm autobrowse record buy-domain --site cloudflare --url https://dash.cloudflare.com/ --terminal
pnpm autobrowse compile buy-domain # → src/workflows/buy-domain/ with its outline.json (on the Runs page at once; no restart)
pnpm autobrowse try google-name # run a compiled workflow here, no Restate: the proof it is deterministic
pnpm autobrowse compile buy-domain --no-llm --from-outline
pnpm autobrowse compile buy-domain --finish # …then a model finishes it until tsc and its test pass
pnpm autobrowse finish buy-domain --brief "the Buy button charges the card"
pnpm autobrowse domains wren automation --max 15 # sending-domain ideas: ours / free at $price, from Cloudflare
pnpm autobrowse domain wren-six.com --inbox will:William:Jin --inbox hello:William:Jin --dry-run
pnpm autobrowse domain wren-six.com --inbox will:William:Jin --inbox hello:William:Jin
pnpm autobrowse try domain --plan plan.json --ask # same workflow in-process, no Restate (more inboxes on a live domain too)
pnpm autobrowse inbox [email protected] Will Jin # one inbox, ready: user, sign-in, signature, Gmail consent, accounts row
pnpm autobrowse warmup-match [email protected] --dry-run # which Instantly inboxes differ from that one's warmup settings; drop --dry-run to copy them
pnpm autobrowse workflows # what this worker can run
pnpm autobrowse flows --site reddit # every deterministic leg: hand-written flows + the routes on them, compiled workflows + proof, walks
pnpm autobrowse workflows bootstrap # its steps (! = irreversible) and every input: type, default, what it is
pnpm autobrowse workflows bootstrap --template > plan.json # fill in, then: run bootstrap cf --plan plan.json
pnpm autobrowse runs --limit 20 # the registry, newest first (--before <cursor> pages)
pnpm autobrowse explored google # explore runs kept for good: driver, goal, outcome, answer tokens (show <run>: its shape, no values)
pnpm autobrowse walks build google admin-sso --goal-like "sso" # runs that reached a goal → a walk, run as google/walk-admin-sso
pnpm autobrowse teach scratch join-list --url http://127.0.0.1:8765/ # do it by hand, type done; review the guesses (--yes takes them), --mod <dir> packs it
pnpm autobrowse walks run google/admin-sso --plan domain=x.com # no model in the loop; --yes when it has a final act, --profile <id> for whose details
pnpm autobrowse mods pack scratch --out ./mod # your walks, screens, fixes, --login for a site, scrubbed; you npm publish it
pnpm autobrowse mods search [words] # npm mods: sites, domains, gates, code or data
pnpm autobrowse mods add <npm-name|dir|tgz> # checks hashes, domains, gates, asks yes; code needs --trust; mods list, mods remove
# (the UI Mods page lists, searches, adds data mods, removes; sample: walkthrough/mods/)
pnpm autobrowse tokens --days 30 # model calls by purpose, explore answers vs whole-page reads, the verdict
pnpm autobrowse status domain wren-six.com # every run is <workflow> <key>
pnpm autobrowse approve domain wren-six.com purchase
pnpm autobrowse approve domain wren-six.com human # after doing what the email asked
pnpm autobrowse reject domain wren-six.com purchase
pnpm autobrowse pause domain wren-six.com
pnpm autobrowse play domain wren-six.com
pnpm autobrowse run domain wren-six.com # after a failure: resumes the stored plan
# A plan that names a file on this Mac (a photo to upload) ships it to the shots bucket's inputs/
# first; the box can read only that folder, and files there expire after a week.
pnpm autobrowse reset domain wren-six.com
pnpm autobrowse desktop # the Mac outside the browser: apps, menus, root commandspnpm gates = lint + typecheck + tests (the Restate test needs Docker).
pnpm test:live runs the calls that cost money (Backboard, the configured
LLM) when .env has the keys; skipped otherwise.
Containers
docker compose up -d --build # Restate + worker; .env supplies every secret
open http://localhost:9080 # UI (host side is loopback-only)
HOST_INGRESS_PORT=18080 HOST_ADMIN_PORT=19070 HOST_UI_PORT=19080 docker compose up -d # beside another RestateThe worker registers itself with Restate on start (RESTATE_ADMIN_URL +
RESTATE_ENDPOINT_URL). wren is Lambda behind Restate Cloud;
autobrowse is a long-lived container because browser steps run for
minutes and hold a profile.
AWS
The AWS box was retired on 2026-10-02 (cost). Prod is the desk worker on the
operator's Mac, which dials Restate Cloud's tunnel (RESTATE_TUNNEL_NAME +
RESTATE_ENVIRONMENT_ID + RESTATE_CLOUD_REGION + RESTATE_IDENTITY_KEY)
and registers the tunnel URL itself. deploy/README.md is the runbook.
IDLE_STOP_MINUTES still stops an EC2 host the worker runs on, if one is
ever used again.
Owners
One autobrowse serves many businesses, one per process. --owner <name> or
AUTOBROWSE_OWNER picks whose accounts, files, SSM path and Restate names
load; the default wren keeps every existing name. An owner's files live in
~/.config/autobrowse/owners/<name>/ (its .env holds its accounts), its
secrets under SSM /autobrowse/owners/<name>/, its services as
sites_<name>, Runs_<name>, …. Every AWS call it makes assumes the owners
role (AUTOBROWSE_OWNER_ROLE_ARN, terraform output owner_role_arn) tagged
with its name, so it reaches its own paths only. The operator's tools (LLM,
search, browsers) are shared; its accounts, cards and phone are not.
pnpm autobrowse --owner acme needs # what acme still owes
AUTOBROWSE_OWNER=acme RESTATE_PORT=9181 UI_PORT=9180 pnpm worker # acme's worker beside wren'sWhy and the full split: designs/2026-09-30-owner-keys.md.
Layout
src/engine/ workflow/step types, effects seam, guards, run (advance/answer), the Restate run object, Runs registry, events
src/browser/ session (profiles, Browserbase), lock, flow runner (trace, hand-off, fp.act), locate, repair, flows/
src/clients/ http.ts (timeouts, retries, safe errors) + one client per API
src/auth/ site logins, code sources (totp, email), enrollment, the page guard; the vault itself is credvault (keep.ts = our names in it)
src/llm/ Llm seam: anthropic, openai, cohere, claude-code, fake; completeJson; OTLP trace sink
src/memory/ Memory seam: in-process store, Backboard; what repairs and gate answers taught us
src/recorder/ observer (in page), browser + terminal capture, redaction, store
src/compiler/ structure → outline → render (+ polish); output typechecks
src/channels/ email, phone (iMessage on this Mac), linq, webhook, inbound command parser
src/deps/ SecretSink (env file), Shell: what workflows read and write
src/devices/ what a person owns and a second step leans on: the paired phone (SMS in, iMessage out)
src/explore/ explore mode: one open browser, a loopback command API, pause/resume with hand acts journaled
src/runs/ run history (one chained file per explore session) and the token report
src/walks/ walks: built from runs that reached a goal or taught by hand (teach.ts), run as `<site>/walk-<name>`
src/mods/ mods: walks, screens, fixes, logins (code with --trust) packed to share; installed under `mods/`, the owner's own win
src/agent/ the exploration agent (digest, one act a step), sessions (play/pause, persisted), repair, evaluator
src/workflows/ one dir per workflow; domain + bootstrap hand-written, compiled ones are served as they appear (one `Compiled` object)
src/ui/ Hono API (+ SSE bus, bearer, rate limit) and the static SPA
src/app/ settings, composition root (lazy deps), self-registration, status, sentry, CLI, worker
Dockerfile, compose.yml the deploy unit; designs/2026-09-19-deploy.md
ui/ React SPA (Vite); ui/dist is served by the worker
designs/ architecture and per-workflow design docs
map/ system map: nouns, verbs, what a change hits (map/CLAUDE.md)
test/ one file per module; restate.test needs DockerChannels
Gates, failures and finishes reach the operator on every configured
channel, and a reply on any of them is a command (yes, no, pause,
play, status, reset, optionally <workflow> <key>):
- email (a fleet inbox →
NOTIFY_TO) - the paired iPhone (
PHONE_NUMBER; Messages on this Mac, no vendor) - Linq (
LINQ_API_KEY+LINQ_NUMBER; iMessage from a number we own, no Mac; replies arrive atPOST /hooks/linq, verified withLINQ_WEBHOOK_SECRET; the same chats serve SMS one-time codes) - a webhook (
WEBHOOK_URL) - the caller's own hook, per run: start a run with
x-feed-url(https, host inFEED_HOSTS),x-feed-tagand an optionaltraceparent, and its events are posted back as{ tag, traceparent?, events: [{ seq, event }] }about once a second, bearerFEED_TOKEN; best effort, never fails the run
With SENTRY_DSN set, failed runs, failed steps and crashes become
Sentry issues tagged workflow/key/step.
Agent sessions are runs too: rows in the Runs registry and on the live
feed under workflow agent (started, one step per agent step, finished;
a session that dies with the worker is finished as failed on the next
start). Every step is also a row of the hash-chained step ledger
(pnpm autobrowse steps --last 50 [--session id]): model, tokens, act,
outcome, time, host; never a page, a prompt or a value. With
OTEL_EXPORTER_OTLP_ENDPOINT (+ OTEL_EXPORTER_OTLP_HEADERS for the
vendor's auth, OTEL_SERVICE_NAME) every model call is a span over
OTLP/HTTP, one trace per session — Langfuse, Honeycomb, Grafana, Jaeger
take it as is; no SDK, nothing runs without the endpoint. Spans carry
sizes, hashes and token counts, never text (src/llm/trace.ts), under the
OpenTelemetry GenAI names (gen_ai.usage.*), and the sink flushes when the
process exits so a one-call CLI does not take its batch with it.
Langfuse is the default endpoint, set up the way every key here is — nothing is copied by hand:
pnpm autobrowse site setup langfuse project-keys # mints a key pair in the browser, keeps both
pnpm autobrowse site setup discord bot-token # a bot on discord.com/developers; DISCORD_APP_ID + DISCORD_BOT_TOKEN kept
pnpm autobrowse site setup discord invite # the bot joins your server (channels, roles, webhooks; not admin)
pnpm autobrowse site call discord POST "/channels/<id>/webhooks" --body '{"name":"intake","keep":"LANDER_DISCORD_WEBHOOK"}' # URL kept, never printed
pnpm autobrowse site call discord PUT "/guilds/<id>/layout" # a category + channel + webhook per sales lane; idempotent
pnpm autobrowse site call discord PATCH "/guilds/<id>" --body '{"icon":"../lander/public/brand/wren-pfp.png"}' # server icon
pnpm autobrowse langfuse wire # derives the three OTEL names from them
pnpm autobrowse langfuse check # the door opens
pnpm autobrowse langfuse recent --minutes 30 # the spans that actually landedsite call langfuse GET /api/public/v2/observations reads the same spans
through the site facade. Point the OTEL names elsewhere and nothing above
the sink changes.
Coupling to wren
Thin: writes the roster to SSM, dispatches wren's deploy, calls its ingress to start loops. No imports in either direction.
OAuth sign-in
A site behind "Continue with Google" needs no password of its own:
pnpm autobrowse creds via new-tool google --url https://new-tool.test/login
pnpm autobrowse login new-tool # presses the button, signs in as the stored google account
pnpm autobrowse creds set google@ops # a second Google account, then:
pnpm autobrowse creds via other-tool google --account [email protected] --url https://other-tool.test/loginProviders live in src/auth/providers.ts (google, github, microsoft); a via
credential on any site takes that path, spec or not. API consent
(site setup youtube consent) is the hand-written google/oauth-consent
flow: chooser, unverified-app warning, scope boxes, Continue, until the
loopback redirect. Design: designs/2026-09-20-oauth-sign-in.md.
Three resources answer with what you may do to them next: a run
(GET /api/runs/:workflow/:key) carries actions — the gate's approve
and reject while one is open, pause/play, run, reset — a need
carries the setup step that clears it, and a site carries the setup steps
whose inputs are in hand. Legality lives in state, so an agent reads it
instead of guessing a vocabulary (src/ui/affordances.ts). Nothing else is
hypermedia; the site facade keeps each vendor's own shape.
Site APIs
Which account a site posts as is configuration, not a habit: YouTube writes
(upload, thumbnail, comment, community post) check the token's own channel
against YOUTUBE_CHANNEL_ID and refuse when they differ or when it is unset,
so a stale consent cannot put Wren's video on a personal channel. Point it at
another channel and consent as that account to move it.
A service under its own API's shape: POST /api/sites/linkedin/rest/posts
takes what LinkedIn's Posts API takes and answers what it answers; GET
/api/sites/youtube/youtube/v3/videos?part=statistics&id=… is the Data API.
Behind one route the official API answers when a token is in hand, a browser
flow otherwise (gated reads, community posts). The caller has one client.
Sites: linkedin, youtube, instagram (Graph API, long-lived token),
tiktok (Content Posting API; client_key), outlook (Microsoft Graph:
mail and calendar, /me/messages, /me/sendMail, /me/events), gmail
(/gmail/v1/users/me/...; proven 2026-09-22), drive (/drive/v3/..., read
only: list a folder, export a Doc as text; proven 2026-10-01), meta (one Facebook Login
app: Marketing API campaigns/ad sets/creatives/ads/insights under
/act_{id}/…, Page posts/photos/videos with the Page's own token, Instagram
publishing for the Page's professional account), x (API v2: /2/tweets,
/2/users/me, a user's posts, metrics, /2/media/upload from a local
image or video — chunked and waited on; OAuth 2.0 with PKCE, the refresh
token rolls on every mint and is kept). Instagram, TikTok, Outlook, Meta
and X are written from the public docs and unproven until a credential
and a developer app exist.
A route that can start spending (spends: a campaign, ad set or ad set to
ACTIVE, a status update to it) goes through the same payment gate as a
browser "Buy" button before the API leg runs: the spend policy, then the
person, with the request's budget as the amount (daily_budget: 2000 →
20.00/day). No channel to ask on → refused.
One site, any number of identities: site setup gmail consent --account
[email protected] runs the consent in the google@<label> profile whose credential
has that username and keeps the token as GMAIL_REFRESH_TOKEN__WILL_X_DEV;
site call … --account [email protected] uses it. A consent without --account is
kept under whoever consented too (the site's identity call says who). Code
reading and mail sending act as a consented address through its own token,
so any Google inbox works, not only our Workspace (the service account's
domain-wide delegation stays the path for those).
pnpm autobrowse site # sites, token state, setup left
pnpm autobrowse site status linkedin # every route: api | browser | none (why)
pnpm autobrowse site route youtube POST /upload/youtube/v3/videos # one route's inputs; --template for a body file
pnpm autobrowse site check # one who-am-I call per site: is each token alive
pnpm autobrowse read https://firm.test --max 4000 # a page as plain text, no browser
pnpm autobrowse search fee-only RIA Austin -n 5 # web search: exa, brave, then duckduckgo
pnpm autobrowse maps "ria in austin tx" --max-minutes 10 # Google Maps listings as a CSV (Docker); wren --format google-maps
pnpm autobrowse env set EXA_API_KEY_2 --clipboard # more Exa keys: EXA_API_KEY_1..n with NUM_EXA=n; one out of credit is skipped until the 1st
pnpm autobrowse site call web GET "/linkedin/profile?url=linkedin.com/in/<vanity>" # from Exa's cache; LinkedIn sees nothing
pnpm autobrowse site call web GET "/linkedin/company?url=linkedin.com/company/<handle>"
pnpm autobrowse site call web GET "/companies?domain=acme.com" # Exa's company search: LinkedIn page, homepage match
pnpm autobrowse doctor # which read/search/maps backends and site tokens work now
pnpm autobrowse site call linkedin GET "/search/results/people?keywords=ria%20founder%20austin&pages=2" # as Wren's LinkedIn
pnpm autobrowse site call linkedin GET "/in/<vanity>?company=true" # one profile, every role, current employer's page
pnpm autobrowse site call linkedin GET "/company/<handle>" # website, size, industry, HQ, phone
pnpm autobrowse people ria founder austin --pages 2 --enrich # lead CSV, resumable; wren email import-people <file> --format linkedin --niche <niche>
pnpm autobrowse site call linkedin GET "/company/<handle>/people?keywords=founder"
pnpm autobrowse site setup youtube oauth-client # a browser flow on Cloud Console keeps the client id/secret
pnpm autobrowse site setup youtube consent # OAuth consent in the logged-in profile; refresh token kept
pnpm autobrowse site setup gmail consent --account [email protected] # another account's inbox: token under its own name
pnpm autobrowse site call linkedin POST /rest/posts --body '{"author":"urn:li:person:…","commentary":"hi"}'Keys and tokens land in the env store (autobrowse env) through the same
sink keep uses. OAUTH_PORT (9400) is the loopback redirect the OAuth
clients register. The same facade is the Restate service sites
(sites/call, sites/status, sites/setup) for an orchestrator on the same
Restate (wren's Content service): no port on the box, a call queues while
the box is down, a write runs once. Design: designs/2026-09-21-site-apis.md.
Chores on those APIs
pnpm autobrowse unsubscribe --days 30 # every recent sender with List-Unsubscribe: count, path, latest subject
pnpm autobrowse unsubscribe --yes --keep wrenautomation.com # leave them: one-click POST, else mailto, else the link in the browser
pnpm autobrowse unsubscribe --yes --only [email protected],[email protected] # a subsetOnly mail that says how to leave it is listed (a receipt, a code, a person
has no List-Unsubscribe); nothing leaves without --yes. The list is
src/chores/unsubscribe.ts.
pnpm autobrowse aws-login --user william # renew the Mac's AWS CLI session: `aws login --remote` answered from the aws profile
pnpm autobrowse aws-login --user william --overwrite # …even when the profile holds another identity's (root) sessionaws login --remote prints an authorize URL and waits for a verification
code; the signed-in aws browser profile picks the console session and
reads the code off the Copy button (the code view elides it). The code goes
straight into the CLI's stdin: nothing prints it, no file holds it. The
CLI then refreshes its own credentials until the console session expires.
src/chores/aws-login.ts, flow src/browser/flows/aws-cli-login.ts.
pnpm autobrowse wrangler-login --cwd ../lander # renew wrangler's Cloudflare OAuth token
pnpm autobrowse cloudflare-token wren-workers --env WREN_CLOUDFLARE_WORKERS_TOKEN --perm "Account:Workers Scripts:Edit" "Zone:DNS:Edit" # a scoped API token, minted + verified + stored under its own keywrangler login --browser=false prints an authorize URL and waits on
localhost:8976. The Cloudflare session (the Google profile, since Cloudflare
signs in via Google) opens it and clicks Authorize; wrangler keeps its own
token. Run it on the machine the browser runs on. --scopes narrows the
grant. src/chores/wrangler-login.ts, flow src/browser/flows/wrangler-login.ts.
pnpm autobrowse gcloud-login # renew gcloud's credentials as its active account
pnpm autobrowse gcloud-login [email protected]gcloud auth login --no-launch-browser prints a Google authorize URL and
waits for a verification code. The Google profile consents as the account
(chooser, re-verify, scopes); the code comes back in the redirect URL and
goes straight into the CLI's stdin. Run it when gcloud says
"Reauthentication failed". src/chores/gcloud-login.ts, flow
src/browser/flows/oauth-consent.ts.
Use as a library
Every layer is a plain function over explicit parts; only the composers
(loadSettings, sitesFor defaults, backendFor) read the process. Take
the whole thing, one site, or one piece. pnpm build emits dist/ with
types; the subpaths are in package.json exports.
The whole thing: one verb.
import { doerFor } from "autobrowse/do";
const verb = doerFor({ llm, catalog, browser, sink, sites, agent, compile }); // or doer({...}) over your own legs
await verb.do({ goal: "upload this to youtube", inputs: { file: "talk.mp4" } });Or over HTTP/Restate/MCP from any language: POST /api/do, do/run, the
do tool. Sites, workflows and flows you hand abilitiesOf are what it can
route to; anything else the agent explores once.
One site, your wiring. The facade takes your env store, HTTP client,
sink, flow catalog and site list; nothing is read from process.env unless
you leave env out.
import { sitesFor, youtube } from "autobrowse/sites";
const sites = sitesFor({ sites: [youtube], env: (n) => vault.get(n), sink: vault, http, flows, catalog, browser, oauthPort: 9400 });
await sites.status("youtube"); // routes: api | browser | none (why); setup left
await sites.setup("youtube", "consent"); // refresh token → your sink
await sites.call("youtube", "GET", "/youtube/v3/videos?part=snippet&mine=true", {});One piece. Your own token against a route's API leg; your own consent page opener against the token exchange; a site login on a page you drive.
import { youtube, runConsent, accessTokens } from "autobrowse/sites";
const videos = youtube.routes.find((r) => r.path === "/youtube/v3/{resource}");
await videos.api({ resource: "videos", part: "snippet", mine: true }, { token, http });
const mint = accessTokens(http, env); // refresh token → bearer, cached
await runConsent(spec, { http, env, open: ({ url }) => myBrowser.consent(url), port: 9400 });
import { SITE_LOGINS, signInContext, signInToGoogle } from "autobrowse/auth";
await signInToGoogle(signInContext({ fp, site: "google", cred, credentials, codes }));
import { consentFlow, googleOauthConsent } from "autobrowse/flows";
import { digest, exploreWithAgent } from "autobrowse/agent";Adding a site is one module: a SiteApi (routes with request schema,
api leg, browser leg, setup steps that say which env names they make
and need) and, when it has a login, a SiteLogin. route() infers the
input type from the schema. Setup steps are the collectable part: a step is
a recorded flow on the developer console or an OAuth consent, and setup
runs it into whichever sink you pass (the .env, SSM, your vault).
