offbook
v0.3.0
Published
**Teach a browser task once. Replay it forever, for free.**
Readme
offbook
Teach a browser task once. Replay it forever, for free.
You (or your coding agent) walk through a web task one time. offbook leaves behind an ordinary Playwright script, checked into your repo, that does the same thing every day after that — no LLM, no tokens, no API bill. When the site changes and the script breaks, a bounded repair loop puts it back on its feet.
Inspired by Microsoft Webwright: pay the model once, then run for nothing. In the theater, an actor who is off book no longer needs the script in hand.
agent writes script.ts ──► replays free, forever
│ │
│ selector breaks?
│ ▼
│ classify → diagnose → one bounded
└────────────── AI repair → re-verify → done
(anything else fails closed)Install
bun add offbook # or: npm install offbookWorks under Bun and Node ≥ 22.6. A stealth Chromium (cloakbrowser) downloads itself the first time you launch a browser.
Check your setup any time:
offbook doctorQuick start: your first automation
1. Create a task
offbook new price-checkThat scaffolds two files. These two files are the whole model — everything else is cache you can delete.
tasks/price-check/
├── plan.md ← what "done" means, in plain language
└── script.ts ← the Playwright script that does it2. Say what "done" means
Open tasks/price-check/plan.md and write the task plus a checklist of
Critical Points — the things that must be true for the run to count:
# Task
Get the current price of the book on example-store.com/book/123.
# Critical Points
- [ ] CP1: Product page for book 123 is loaded
- [ ] CP2: Price is captured from the pageCritical Points aren't decoration. They're the contract the script is checked against, and they're what the repair loop reads when something breaks.
3. Write the script
tasks/price-check/script.ts starts as a working template. Fill in the middle:
import { openBrowser } from "offbook/browser";
import { act, installFailureHandler } from "offbook/act";
import { openLog, resolveRunDir, screenshot } from "offbook/trace";
installFailureHandler();
const RUN_DIR = resolveRunDir();
const log = openLog(RUN_DIR);
const { page, close } = await openBrowser();
try {
// CP1: load the product page
await page.goto("https://example-store.com/book/123", { waitUntil: "domcontentloaded" });
await screenshot(page, RUN_DIR, 1, "product_page");
log(1, "product page loaded");
// CP2: capture the price
const price = await act(2, page.locator(".price"), (el) => el.innerText());
await screenshot(page, RUN_DIR, 2, "price_visible");
log(2, `price: ${price}`);
log.final(price);
} finally {
await close();
}Three things earn their keep here:
act(step, locator, fn)wraps any interaction that matters. If it times out, offbook re-probes the page and tells you which selector broke and how — missing, hidden, or unclickable. That classification is what makes a break automatically repairable later.screenshot(...)— one per Critical Point. Proof the contract held.log.final(value)— the answer the run produces.
4. Run it
offbook run price-checkEvery run writes a step log and screenshots into a fresh evidence directory. Nothing overwrites anything.
offbook log price-check # what happened last run
offbook runs price-check # every run's evidence dir
offbook diff price-check # what changed between two runs5. Log in once, replay headless forever
Most useful tasks are behind a login. Do it visibly, one time:
OFFBOOK_HEADLESS=0 offbook run price-checkA real browser window opens; log in by hand. Auth state persists in
~/.offbook/profiles/, so every later run replays headless with no login
step. Profiles live outside your repo — they hold cookies, and cookies never
belong in git.
6. When the site changes
Sites drift. Selectors rot. Instead of debugging by hand:
offbook repair price-checkoffbook runs the task, and only if it fails on a selector it recognizes, it lets an AI make exactly one repair attempt and re-runs to verify. Anything else — login walls, MFA prompts, a page that silently returns different data — fails closed and waits for you. Details in Self-repair below.
Turning a task into a reusable CLI
The task above is hardcoded to book 123. Usually you want to run the same
automation with different inputs. Add a # Parameters table to plan.md:
# Parameters
| name | type | default | format |
|---------|--------|---------|----------------|
| bookId | string | "123" | numeric string |Then shape script.ts as one exported function plus a CLI wrapper:
import { parseArgs } from "node:util";
export async function priceCheck(args: { bookId: string }): Promise<{ price: string }> {
/**
* Get the current price of a book on example-store.com.
*
* @param bookId The product id. Default: "123".
* @returns Object with key `price` (string).
*/
const { page, close } = await openBrowser();
try {
await page.goto(`https://example-store.com/book/${args.bookId}`, { waitUntil: "domcontentloaded" });
const price = await act(2, page.locator(".price"), (el) => el.innerText());
return { price };
} finally {
await close();
}
}
// CLI wrapper — only runs when the file is executed directly, never on import
if (import.meta.url === `file://${process.argv[1]}`) {
const { values } = parseArgs({ options: { bookId: { type: "string", default: "123" } } });
console.log(await priceCheck({ bookId: values.bookId! }));
}Now it's both a library function and a command-line tool:
node tasks/price-check/script.ts --bookId 456Two rules make this work: running with no arguments must reproduce the original task exactly, and importing the file must not launch a browser. Things genuinely fixed for the site (the base URL, the selector strategy) stay hardcoded — they're not parameters.
If you're driving a coding agent, /offbook:craft <task> produces this shape
for you; /offbook:run <task> produces the one-shot version. The full
authoring protocol for agents is in SKILL.md.
All CLI commands
offbook new <task> # scaffold plan.md + script.ts
offbook run <task> # execute; fresh evidence dir per run
offbook repair <task> # run; on an eligible failure, one AI repair attempt
offbook log <task> # latest run's step log
offbook runs <task> # list run evidence dirs
offbook diff <task> # compare two runs
offbook list # tasks in ./tasks
offbook doctor # validate setup (--fix downloads the browser binary)Useful environment variables:
| Variable | What it does |
|---|---|
| OFFBOOK_HEADLESS=0 | Show the browser window (first login) |
| OFFBOOK_PROFILE | Named profile, default "default" |
| OFFBOOK_ENGINE | Browser engine (see below) |
| OFFBOOK_PROXY | Proxy URL |
| OFFBOOK_MODEL | Repair model, e.g. anthropic/claude-sonnet-5 |
Where things live
| Path | What | Lifecycle |
|---|---|---|
| tasks/<name>/ | plan.md + script.ts | committed to git |
| ~/.cache/offbook/runs/<task>/run_N/ | step log + screenshots | prunable evidence |
| ~/.local/share/offbook/artifacts/<task>/run_N/ | downloads, exports | yours to consume, then delete |
| ~/.offbook/profiles/<name>/ | browser auth state | machine-local, never in a repo |
Only the first row belongs in version control. Everything else is regenerable, and the durable-artifacts row is where files a task downloads wait for you to pick them up.
Browser engines
offbook drives five interchangeable engines. Switch with --engine on
offbook run, the OFFBOOK_ENGINE env var, or openBrowser({ engine }).
Default is cloakbrowser — if you don't care, you never have to think about
this section.
Why you might: to A/B whether a site's bot detection is actually triggering, or to run against a hosted browser service instead of a local one.
| Option | cloakbrowser | camoufox | playwright | obscura | cdp | |---|---|---|---|---|---| | Persistent profile | native | native | native | storageState | storageState | | humanize | ✅ | ✅ | — | — | — | | proxy | ✅ | ✅ | ✅ | ✅ | service-side | | locale / timezone | ✅ | locale | emulation | — | — | | launchArgs | ✅ | — | ✅ | — | — | | headless | ✅ | ✅ | ✅ | headless-only | remote |
Unsupported options warn once and are ignored — switching engines never
errors out on you. Validate any engine with offbook doctor --engine <name>
(or --engine all).
proxy accepts http://user:pass@host:port; embedded credentials are
supported on every engine that supports proxy at all, but not uniformly:
cloakbrowser and obscura's own HTTP client parse userinfo out of the URL
themselves, while playwright's adapter splits it into separate
username/password fields before handing it to Chromium — Chromium ignores
credentials embedded in proxy.server and silently fails auth (407) if they
aren't split out. This split happens once, centrally, in parseProxy()
(lib/browser.ts), so every adapter agrees on the shape.
cloakbrowser (default) — stealth Chromium
offbook run mytaskconst { page, close } = await openBrowser(); // engine: "cloakbrowser"camoufox — stealth Firefox (optional)
bun add camoufox-js # then fetch the browser build per camoufox-js README
offbook run mytask --engine camoufoxconst { page, close } = await openBrowser({ engine: "camoufox", humanize: true });playwright — vanilla Chromium (detection control)
offbook run mytask --engine playwrightThe un-stealthed baseline. If a site blocks playwright but passes
cloakbrowser/camoufox, the stealth layer is doing its job.
obscura — Rust CDP engine
# Install from github.com/h4ckf0r0day/obscura/releases (or set OFFBOOK_OBSCURA_BIN)
offbook run mytask --engine obscuraoffbook spawns obscura serve on a free port and connects, then shuts it down
with the browser. Point --cdp-url/OFFBOOK_CDP_URL at an already-running
instance to skip the spawn. obscura has no headed mode, so headless warns
once and is ignored. --proxy gets the full URL string as-is (including
user:pass@ if present) — obscura's Rust HTTP client parses embedded
userinfo and builds the Proxy-Authorization header itself, so credentials
work without any splitting on offbook's side.
cdp — any hosted CDP endpoint
Cloudflare Browser Run (Kitesurf):
offbook run mytask --engine cdp \
--cdp-url "wss://api.cloudflare.com/client/v4/accounts/<ACCOUNT_ID>/browser-run/devtools/browser?browser=kitesurf" \
--cdp-header "Authorization: Bearer <CF_API_TOKEN>"browserless:
offbook run mytask --engine cdp --cdp-url "wss://chrome.browserless.io?token=<TOKEN>"const { page, close } = await openBrowser({
engine: "cdp",
cdpUrl: process.env.CDP_URL,
cdpHeaders: { Authorization: `Bearer ${process.env.CF_TOKEN}` },
});Prefer OFFBOOK_CDP_HEADERS (a JSON object) over --cdp-header for real
secrets — command-line arguments are visible to other users via ps. Header
values are never logged. offbook doctor and connection-failure errors
redact userinfo (user:pass@) and sensitive query params (token, apiKey,
access_token, key, auth) out of any CDP URL before printing it.
One exception offbook can't close: the obscura engine accepts a proxy only
as a --proxy command-line argument, so a proxy URL with embedded credentials
is visible to other local users via ps for the life of that process. Use an
unauthenticated or IP-allowlisted proxy with obscura, or run it yourself and
connect with --cdp-url.
Two things to know about the CDP-based engines (obscura, cdp): they
persist cookies and localStorage per profile but not service workers or
IndexedDB, so some sites re-challenge you across runs. And on affected Bun
versions they run their script under node automatically — Playwright's
connectOverCDP handshake hangs under Bun
(oven-sh/bun#32526, fixed in Bun
1.4.0; every release so far is older, so the fallback is currently always on).
The check is version-gated, so a Bun ≥ 1.4.0 keeps these engines on Bun with no
offbook change. offbook run/offbook repair switch runtimes for you; if you
execute a script by hand on an affected Bun, use node script.ts. Escape
hatch: OFFBOOK_ALLOW_BUN_CDP=1.
Developer reference
Everything the CLI does is an exported module. Skip this section unless you're embedding offbook in another system.
Run a task programmatically
import { allocateRunWorkspace, runTask } from "offbook/runner";
const workspace = allocateRunWorkspace({
task: "cibc-export",
artifactRoot: "/my/app/intake", // durable outputs land here, run_N per run
});
const result = await runTask({ taskDir: "tasks/cibc-export", workspace });
// { status: "ok" | "failed", exitCode, workspace, failure?, logTail? }No process.exit, no globals: structured results, and run numbers are never
reused while evidence or durable artifacts survive. Library callers pass
artifactRoot explicitly, so downloaded bytes land where your system already
looks instead of in a directory nobody watches.
Instrument scripts for repairability
import { act, installFailureHandler } from "offbook/act";
installFailureHandler();
await act(3, page.locator("button.submit"), (b) => b.click());When a wrapped interaction times out, act re-probes the locator and throws a
classified failure — selector-not-found, selector-not-visible, or
selector-not-actionable — with the selector and step attached. That
classification is what makes a failure eligible for repair.
Self-repair: how the safety comes from structure
import { runWithRepair } from "offbook/repair";
import { aiRepairProvider } from "offbook/repair-ai";
const report = await runWithRepair({
taskDir: "tasks/cibc-export",
artifactRoot: "/my/app/intake",
provider: aiRepairProvider(), // model from OFFBOOK_MODEL / API keys
});
// outcome: "ok" | "repaired" | "failed-closed"The bound is structural, not prompted: only the three selector failure kinds ever reach a provider, the provider is called exactly once, and a second failing run fails closed. Auth changes, MFA, semantic drift, and anything unclassified fail closed immediately. The orchestrator never runs git and never applies patches itself — your code decides whether a proposal lands.
The built-in provider is a minimal agent whose tool whitelist is the safety boundary: read the plan, the script, and sanitized diagnostics; probe live pages only on origins already present in the script; propose exact-match edits; finish. Edits revert on every refusal path.
OFFBOOK_MODEL="anthropic/claude-sonnet-5" # or openai/... or openrouter/...
# defaults: ANTHROPIC_API_KEY → claude-sonnet-5, OPENROUTER_API_KEY → gpt-5.6-lunaBring your own provider by implementing one method:
import type { RepairProvider } from "offbook/repair";
const provider: RepairProvider = { repair: async (request) => ({ status: "refused", changedFiles: [], summary: "..." }) };Safe forensics
import { collectDiagnostics, defaultScrubbers } from "offbook/sanitize";Forensic capture is bounded by default — full-DOM dumps are explicit opt-in.
Repair requests carry only SanitizedDiagnostics: the classified failure, a
bounded log tail, a sanitized ARIA snapshot, and screenshot names. Never
cookies, storage, input values, or downloaded artifacts. Opt-in scrubbers mask
digit runs, emails, and token blobs.
All exports
offbook/browser (persistent-profile browser launch across all five engines;
locale, timezone, downloads, humanization presets) · offbook/runner ·
offbook/act · offbook/errors · offbook/trace (step log, screenshots,
forensics) · offbook/plan (parse plan.md, correlate evidence to Critical
Points) · offbook/sanitize · offbook/repair · offbook/repair-ai ·
offbook/workspace · offbook/paths · offbook/diff
Agent workflow
SKILL.md documents the full authoring protocol for coding agents: plan the
task as Critical Points, explore with scratch scripts, author with act()
instrumentation, execute, then self-verify each Critical Point against
screenshot evidence before calling it done. references/engines.md covers
engine selection in the same detail as above.
Requirements
- Bun ≥ 1.1 or Node ≥ 22.6
- macOS or Linux (Chromium binary via cloakbrowser)
