The X-ray vision layer for web automation. A TypeScript Playwright toolkit that captures everything a page does — every fetch, every console log, every interactable element — exposes it through a typed JSON-RPC API, and runs your queue strictly sequentially without dropping a single byte of evidence.
Pair it with
playstealth-clito get the ninja mask (anti-detect, persona binding, human input rhythms).unmasksees,playstealthhides, your agent thinks.
Every commercial web-automation product (Stagehand, Browser Use, Skyvern,
Browserbase) re-invents the same plumbing in its own private repo: CDP wiring,
semantic DOM scanning, an LLM act/extract/observe API, replay bundles for
failure forensics. unmask-cli makes that plumbing a well-documented,
MIT-licensed, TypeScript-first standalone package with a stable JSON-RPC
surface so it can be driven from Python, Go, or any other language.
| Module | Source signal | Output |
|---|---|---|
unmask-network |
Chrome DevTools Protocol (Network.*, Fetch.*) |
Every fetch / XHR with full headers, request body, response body. Survives navigation via Fetch.* body capture. |
unmask-dom |
Live DOM walk + Playwright accessibility.snapshot() |
Every interactable element with a stable, prioritised selector (data-testid > id > aria-label > role+name > text > path). |
unmask-console |
Page.console, pageerror |
Every log / warn / error with stack and severity. |
selfHeal |
Multi-strategy resolver | Locator that survives small DOM changes by falling back through data-testid → aria → role+name → text. |
act / extract / observe mirror the Stagehand API but consume our own
serialized accessibility tree, so the LLM gets the same ground truth our
agents do. The package degrades gracefully: without an AI_GATEWAY_API_KEY
all three fall back to deterministic DOM heuristics.
await act(page, 'click the start button');
const data = await extract(page, z.object({ price: z.number() }));
const candidates = await observe(page, 'all primary CTAs');- Strictly sequential. Exactly one survey at a time. No
Promise.all. - Persistent state. Atomic-write
~/.unmask/state.json, survives crashes. - Blacklist with stored reason and timestamp.
- Telemetry: success-rate, EUR/h, average duration; written as JSONL.
- Webhooks: Slack / Discord / generic JSON.
- Replay bundles: HAR +
trace.zip+ screenshots + JSONL events, packaged per session.
Run as a JSON-RPC 2.0 server on stdio (default) or HTTP+WebSocket so any language can drive it:
unmask serve # stdio, default
unmask serve --http # HTTP at 127.0.0.1:8765A reference Python client lives at
integrations/python/unmask_client.py.
npm install -g unmask-cli # or: pnpm add -g unmask-cli
unmask doctor # check Node / Playwright / LLM / home-dirThe postinstall hook automatically downloads the Chromium binary
(skip with UNMASK_SKIP_BROWSER_INSTALL=1).
unmask inspect https://example.com --out report.jsonreport.json contains the full UnmaskResponse:
unmask init --state-dir .unmask
unmask queue add ./surveys.json --state-dir .unmask
unmask queue blacklist survey-bad --state-dir .unmask --reason "always disqualifies"
unmask queue run --state-dir .unmask \
--telemetry-out ./telemetry.jsonl \
--webhook https://hooks.slack.com/services/...| Command | Purpose |
|---|---|
unmask inspect <url> |
Full one-shot X-ray (DOM + network + console). |
unmask network <url> |
Network-only sniff. |
unmask dom <url> |
DOM-only semantic scan. |
unmask console <url> |
Console + pageerror only. |
unmask init [--state-dir] |
Scaffold an empty queue state directory. |
unmask queue add <surveys.json> |
Enqueue surveys. |
unmask queue list |
Show queue, blacklist, last results. |
unmask queue blacklist <id> |
Persistently skip an item. |
unmask queue unblacklist <id> |
Re-enable a blacklisted item. |
unmask queue run |
Process queue strictly sequentially with telemetry + webhooks. |
unmask queue reset |
Reset state (--keep-blacklist to preserve the blacklist). |
unmask serve [--http] |
JSON-RPC server (stdio default, HTTP+WS optional). |
unmask bundle <session-dir> |
Zip a session into a portable replay bundle. |
unmask doctor |
Self-diagnostic. |
import { launchBrowser, NetworkSniffer, DomScanner, ConsoleListener, selfHeal } from 'unmask-cli';
const h = await launchBrowser({ headless: true, stealth: true });
const sniff = await NetworkSniffer.attach(h.page);
const dom = new DomScanner(h.page);
const con = new ConsoleListener(h.page);
await h.page.goto('https://example.com');
const elements = await dom.scan();
await selfHeal(h.page, { primary: '#missing-button', role: 'button', text: 'start' }).then(
({ locator }) => locator.click(),
);
await h.close();
console.log(sniff.events.length, 'requests captured');echo '{"jsonrpc":"2.0","id":1,"method":"browser.open","params":{"url":"https://example.com"}}' \
| unmask serveMethods: browser.open, browser.close, dom.scan, network.list,
console.list, selfheal.click, act, extract, observe, queue.add,
queue.run, queue.list, bundle.create. See HACKING.md
for the full schema.
┌─────────────────────┐ JSON-RPC 2.0 ┌────────────────────────┐
│ Your agent │ ──────────────────────────▶ │ unmask-cli (Node) │
│ (Python / TS / │ │ ┌──────────────────┐ │
│ anything) │ ◀── events / responses ──── │ │ DOM / NET / CON │ │
└─────────────────────┘ │ │ act/extract/obs │ │
│ │ Queue / Replay │ │
│ └──────────────────┘ │
└─────┬──────────────────┘
│ CDP attach
▼
┌────────────────────────┐
│ playstealth-cli │
│ (Python, Chrome) │
│ - persona profile │
│ - fingerprint patches │
│ - human input │
└────────────────────────┘
playstealth-cli launches the stealth browser and exposes a CDP endpoint;
unmask-cli serve connects with --cdp-endpoint and runs the observation +
queue logic inside that hardened context.
Tracked as GitHub issues. Epics: #27 X-Ray Pro, #28 Intelligence, #29 Operations, #30 Integration, #31 Developer Experience.
MIT — see LICENSE.
{ "url": "https://example.com", "title": "Example Domain", "elements": [ { "selector": "a[href=\"https://www.iana.org/...\"]", "label": "More information…", "confidence": 0.84, }, ], "network": [ { "url": "...", "method": "GET", "status": 200, "requestHeaders": {}, "responseBody": "..." }, ], "console": [{ "type": "warning", "text": "..." }], }