Chrome Browser Control
# Chrome Browser Control
[](https://www.npmjs.com/package/chrome-browser-control)
[](https://www.npmjs.com/package/chrome-browser-control)
[](./LICENSE)
Local Chrome-profile control for stdio MCP hosts.
This project exposes browser-control MCP tools through a Manifest V3 Chrome extension connected to a loopback WebSocket broker. Configure your MCP host to launch the stdio adapter with the same pairing token you enter in the extension.
Repository: https://github.com/vkongv/chrome-browser-control
## Prerequisites
- Node.js 20+
- Google Chrome
## Install and Setup
Preferred path: install the CLI, then run setup.
```bash
npm install -g chrome-browser-control
# or, without a global install:
npx -y chrome-browser-control setup
```
The CLI installs as `cbctl` (preferred short name) and also as `chrome-browser-control`.
```bash
cbctl setup
cbctl start
cbctl doctor
```
`setup` writes `~/.chrome-browser-control/config.env` (pairing token + port), copies the unpacked extension to `~/.chrome-browser-control/extension`, and prints MCP host snippets. Do not commit that directory.
### Agent skill (separate from npm)
The runtime agent skill under `skills/chrome-browser-control/` is **not** shipped inside the npm package. After installing the CLI, obtain the skill from this repository (or skills.sh) if your agent host uses skills.
CLI commands (`cbctl` or `chrome-browser-control`):
| Command | Purpose |
| --- | --- |
| `cbctl setup` | Create user config and install the extension copy |
| `cbctl start` | Start the shared loopback broker |
| `cbctl stop` | Stop the broker |
| `cbctl status` | Show broker / config status |
| `cbctl doctor` | Local setup checker |
| `cbctl mcp` | Stdio MCP adapter (attach-only by default) |
| `cbctl mcp-config` | Print host-specific MCP snippets |
| `cbctl broker` | Run the broker in the foreground (dev) |
From a git checkout (contributors):
```bash
git clone https://github.com/vkongv/chrome-browser-control.git
cd chrome-browser-control
npm install
npm run build
node dist/cli/main.js setup
```
Repo-local `npm run broker` / `npm run mcp` remain available for development against TypeScript sources (with optional repo `.env.local`).
### Environment Variables
- `CHROME_BROWSER_CONTROL_TOKEN` — Required. High-entropy pairing token shared by the broker, MCP adapter, and extension popup.
- `CHROME_BROWSER_CONTROL_PORT` — WebSocket broker port (default `8765`).
- `CHROME_BROWSER_CONTROL_HOST` — Loopback host for the broker (default `127.0.0.1`).
- `CHROME_BROWSER_CONTROL_EXTENSION_ID` — Optional. Pins the broker to one installed extension ID.
- `CHROME_BROWSER_CONTROL_AUTOLOAD` — Optional. Set to `1` so `mcp` may spawn a broker if none is reachable (recovery). Prefer `cbctl start` for normal use.
- `CHROME_BROWSER_CONTROL_DISABLE_LOCAL_ENV` — Optional. Set to `1` to skip loading repo `.env.local`.
User config lives under `~/.chrome-browser-control/` and is loaded before any repo `.env.local`. Process env always wins.
MCP attach-only default: `cbctl mcp` connects to an already-running broker. Start the broker with `cbctl start` first. For recovery, use `cbctl mcp --autoload` or `CHROME_BROWSER_CONTROL_AUTOLOAD=1`.
## Load The Extension
1. Open Chrome with the profile you want the MCP tools to control.
2. Go to `chrome://extensions`.
3. Enable Developer mode.
4. Click "Load unpacked".
5. Select `~/.chrome-browser-control/extension` (printed by `setup`). Contributors editing sources may load `extension/` from the repo instead.
6. Open the Chrome Browser Control extension popup.
7. Keep the bridge URL at `ws://127.0.0.1:8765` unless you changed the local port.
8. Paste the generated pairing token.
9. Add allowed origins such as `https://example.com`, `http://localhost:3000`, or `*` for all normal `http://` and `https://` pages.
10. Click "Save and reconnect".
The extension may ask for host permission for the allowed origins. Denying that request prevents page actions for those origins.
Using `*` is convenient for local development, but it exposes every normal web page in the current Chrome profile to MCP tools. Prefer explicit origins when you only need a few sites. Wildcard mode also asks for the optional `<all_urls>` host permission so Chrome allows visible-viewport screenshots through `chrome.tabs.captureVisibleTab`; the background still blocks non-http(s) URLs and disallowed origins before capture.
## MCP Host Configuration
Paste a snippet from `cbctl setup` (or `mcp-config`) into Cursor, Claude Desktop, Codex, or another stdio MCP host. To print host-specific config again later:
```bash
cbctl mcp-config --host cursor
cbctl mcp-config --host claude
cbctl mcp-config --host codex
cbctl mcp-config --host yaml
```
The MCP server key is `chrome_browser_control`. The adapter command is the installable CLI (`cbctl` preferred) with `args: ["mcp"]` — not `tsx` against `server/index.ts`.
YAML-style example:
```yaml
mcp_servers:
chrome_browser_control:
command: "cbctl"
args: ["mcp"]
env:
CHROME_BROWSER_CONTROL_TOKEN: "<generated-token>"
CHROME_BROWSER_CONTROL_PORT: "8765"
timeout: 60
connect_timeout: 30
```
JSON-style example:
```json
{
"mcpServers": {
"chrome_browser_control": {
"command": "cbctl",
"args": ["mcp"],
"env": {
"CHROME_BROWSER_CONTROL_TOKEN": "<generated-token>",
"CHROME_BROWSER_CONTROL_PORT": "8765"
}
}
}
}
```
If the CLI is not on `PATH`, use the NPX fallback printed by setup: `npx` with `args: ["-y", "chrome-browser-control", "mcp"]`.
If your MCP host uses a config file, keep it private and outside the repository.
## Verify
1. Start the broker: `cbctl start`
2. Run the setup checker: `cbctl doctor`
3. Confirm from your MCP host by calling the `browser_status` tool. When ready, `extension.status` and `ping.status` should reflect a live bridge connection, and `extension.allowedOrigins` should show your configured scope.
## Tools
- `browser_status`: checks whether the MCP adapter can reach the broker and whether the Chrome extension answers `ping`. When ready, `extension.status` and `ping.status` reflect the live bridge connection (not a stale disconnected default), `extension.allowedOrigins` shows the configured scope (including `* (all http/https web origins)` when wildcard mode is enabled), `extension.session` shows session name/claimed tabs, `extension.cdpEnabled` / `extension.attachedTabs` report the trusted-input tier, and `protocolVersion` / `features` confirm the loaded unpacked extension code. Protocol version `7` includes the `cdp-trusted-input` feature marker.
- `name_session`: sets a human-readable session name for status/debugging.
- `list_tabs`: lists tabs whose URL origin is allowed in the extension popup. When every open tab is filtered out, returns `{ tabs: [], detail, hiddenTabCount, allowedOrigins? }` instead of a bare `[]`. Wildcard mode is labeled clearly in `allowedOrigins`.
- `list_frames`: lists current frame documents for an allowed tab using Chrome's frame registry. Operable active HTTP(S) documents include a `documentId`; policy-blocked, host-permission-denied, unsupported, fenced, and non-active rows retain hierarchy/status only and redact URL and document identity.
- `claim_tab`: claims an allowed tab for this browser-control session and returns a `sessionTabId`. Claims are routing state, not exclusive browser locks.
- `release_tab`: releases a claim by `sessionTabId` or `tabId` without closing the tab.
- `finalize_tabs`: releases claim state for the session without closing tabs. Pass `keep` entries to preserve handoff/deliverable claims. Also detaches CDP attachments on every claimed tab, including kept claims.
- `cdp_attach`: attaches trusted input to a claimed tab (`sessionTabId` required). Off by default until the popup `enableCdp` checkbox is on. The `debugger` permission is required in the manifest; Chrome shows a warning at load. Chrome shows a persistent debugging banner on the attached tab. While attached, `click`, `type`, `keypress`, `click_at`, and matching `perform_actions` steps use CDP. Calling `cdp_attach` again on the same tab refreshes the TTL without tearing down the live socket. Cross-origin iframe clicks fail with `CDP_CROSS_ORIGIN_FRAME`; detach or use a same-origin/top document.
- `cdp_detach`: detaches trusted input and returns those tools to the content-script path without releasing the claim.
- `cdp_network_watch`: enables Network on an attached tab and keeps an in-memory metadata index (`requestId`, url, method, status, mimeType, size, timestamp). No bodies are stored. Restricted-category origins never enter the index.
- `cdp_network_requests`: returns that index.
- `cdp_response_body`: reads one body by `requestId` after an allowlist and denylist check. Binary bodies are refused. Bodies over the size cap return an error, never a truncated body. Token-shaped fields receive best-effort masking, which is not a guarantee. Treat every body as credential-bearing. Response headers, `Set-Cookie`, and request post data are never returned.
- `snapshot`: returns a simplified DOM snapshot for an allowed document. By default this is a compact automation snapshot that includes concise actionable elements, a text preview (500 chars), omitted counts, and region summaries. Compact defaults to main-landmark scope when present; a visible modal dialog (`aria-modal="true"` or `<dialog>` opened with `showModal()`) takes that scope instead. Other visible `role="dialog"` nodes are included in the current scope and do not steal it. Hidden or closed dialogs have no effect. Pass `scope: "document"` for the full body (including the page behind a modal), `ignoreRoles: ["dialog"]` or `["alertdialog"]` to hide both `dialog` and `alertdialog`, `ignoreRoles: []` to include them in the current scope, or `mode: "full"` for the unscoped legacy snapshot. Pass `mode: "visible"` for viewport/intersection-aware elements with bounds and scroll metadata. Pass `textLimit` (up to `100000`) when you need more page body text — check `textBytesOmitted` to see if content was truncated.
- `visible_snapshot`: convenience tool for `snapshot({ mode: "visible" })`.
- `navigate`: navigates the active tab or a specified `tabId` to an allowed URL, then waits for the tab to finish loading when possible. By default focus is unchanged (background tabs stay in the background; the focused tab is not deactivated). Pass `active: true` only when the tab must become visible. If loading times out, the result includes `pending: true` and a `warning`. Supports `after` observations after the load wait.
- `activate_tab`: focuses an allowed tab and its Chrome window without changing the URL. It waits until the document reports `visibilityState: "visible"` or a short timeout elapses, then returns `visibilityState` and `visible` along with `active` and `focused`. When `visible` is false, the result also includes `reason` (`hidden`, `unknown`, `host_permission_denied`, or `document_unavailable`). `focused` is not a success signal — a window can stay unfocused on macOS while the document still becomes visible. Use this when click/type/keypress fail with `DOCUMENT_HIDDEN`, or when the tab is already active in an unfocused window. Pass `allowHidden: true` only when `visibilityState` is `hidden`. If `reason` is `host_permission_denied` or `document_unavailable`, grant host permission or reload the tab; `allowHidden` will not help. `screenshot.activated` only reports whether the tab was made active in its window — not whether the window was focused or the document became visible.
- `click`: clicks an element by snapshot ref on an allowed tab. Fails with `DOCUMENT_HIDDEN` when the document is hidden unless `allowHidden: true`. Supports `after` observations.
- `type`: types into an element by snapshot ref on an allowed tab. Password-like fields are blocked unless `force=true`. Fails with `DOCUMENT_HIDDEN` when the document is hidden unless `allowHidden: true`. Supports `after` observations.
- `scroll`: scrolls an allowed tab by `deltaX` and `deltaY`. Optional `x`/`y` viewport coordinates scroll a scrollable element under that point when one is found. Scrolling does not paginate snapshot text — snapshots use full `document.body` innerText. Raise `textLimit` on `snapshot` instead of scroll-stitching unless the page lazy-loads content. Supports `after` observations.
- `query_elements`: returns bounded refs/roles/labels/bounds for elements filtered by CSS selector, role, text, and visibility.
- `extract_elements`: extracts bounded text/html/links/time data from a CSS selector. HTML extraction redacts password/OTP/hidden-token attribute values and marks sensitive items instead of leaking secret values. This is the supported alternative to raw JavaScript evaluation.
- `screenshot`: captures the visible viewport of an allowed tab as a data URL. Optional `ref` or `bounds` (+ `padding`) crop after capture; empty crops fail before `captureVisibleTab`. Uncropped responses omit crop fields. MV3 capture is viewport-only; inactive target tabs may be activated before capture. Chrome requires `<all_urls>` or `activeTab` for `captureVisibleTab`; this extension requests optional `<all_urls>` only in wildcard (`*`) mode, so wildcard screenshots need that popup grant.
- `keypress`: dispatches common DOM keyboard events to the page. Browser/OS-level shortcuts are not guaranteed under MV3. Fails with `DOCUMENT_HIDDEN` when the document is hidden unless `allowHidden: true`. Supports `after` observations.
- `click_at`: dispatches mouse events at viewport coordinates. Fails with `DOCUMENT_HIDDEN` when the document is hidden unless `allowHidden: true`. Supports `after` observations.
- `wait_for`: waits for bounded selector/text/URL-substring conditions and returns match/timeout evidence.
- `page_status`: returns title, URL, ready/visibility state, viewport/scroll state, and resource counts by initiator type. It does not expose request headers or response bodies.
- `console_logs`: returns bounded console logs captured after the content script was injected. It cannot see older page console history.
- `collect_scroll`: scrolls a bounded number of steps (hard ceiling when `until` is set), extracts selected elements each step, optionally targets a nested scroll container via `scroll`, applies an aggregate item cap (`maxItems`, default 100), and optionally dedupes by text or href for lazy feeds. Optional `until.noNewItemsForSteps` / `until.stopBeforeDatetime` (ISO-8601; requires `includeTimes`) set `stoppedReason`. Results include omitted/truncated counts. Supports `after` observations.
- `perform_actions`: runs up to 10 sequential page actions (`click`, `type`, `scroll`, `keypress`) in one broker round-trip. Fail-fast on the first step error; terminal `after` observations run only when every step succeeds. Click, type, and keypress steps fail with `DOCUMENT_HIDDEN` on hidden documents unless that step sets `allowHidden: true`. Scroll steps stay unguarded. Coordinate clicks stay on single-tool `click_at`. Steps cannot carry `after`, `tabId`, or `sessionTabId`.
### Frame document targeting
DOM/content tools accept an optional `documentId` returned by `list_frames`. Omitting it preserves existing behavior and targets the current top document for each operation. Supplying it selects that exact document: if the iframe navigates, disappears, moves to another tab, becomes unsupported, or loses access, the operation fails instead of falling back to the top frame or a replacement using the same `frameId`.
Every content result carries background-attested `documentId`, `frameId`, `isTopFrame`, and `coordinateSpace`. Top-frame coordinates use `tabViewport`; iframe `visible_snapshot` bounds, `click_at`, and coordinate scrolling use `frameViewport`. Iframe-local bounds cannot be passed to screenshot cropping because `screenshot` remains a tab-viewport-only tool. `navigate`, `activate_tab`, `screenshot`, and `list_frames` accept tab targets only; `perform_actions.documentId` applies to the whole batch and cannot be overridden by a step.
Document failures preserve one of these prefixes, including inside batch step errors and `after` failures: `DOCUMENT_STALE:`, `DOCUMENT_POLICY_DENIED:`, `DOCUMENT_HOST_PERMISSION_DENIED:`, or `DOCUMENT_UNSUPPORTED:`. V1 supports only active HTTP(S) outermost/subframe documents. It intentionally excludes `about:blank`, `about:srcdoc`, `blob:`, `data:`, origin-fallback frames, iframe navigation, and iframe-to-tab screenshot coordinate translation.
## Act Then Observe
The action tools `navigate`, `click`, `type`, `scroll`, `keypress`, `click_at`, `collect_scroll`, and `perform_actions` accept an optional `after` object. The extension removes `after` before sending the base action to the content script, then runs requested observations in this fixed order: `waitFor`, `snapshot`, `pageStatus`. The response is the base action result plus an `after` object with the observation results.
For `perform_actions`, `after` applies to the whole batch only: individual steps cannot include `after`, and terminal observations are skipped when any step fails. Partial batch failures return structured step results with `failedIndex` and `completedCount` while preserving bridge-level success so agents can inspect the payload.
```json
{
"ref": "h12",
"after": {
"waitFor": { "selector": ".results", "timeoutMs": 5000 },
"snapshot": { "mode": "visible", "limit": 40 },
"pageStatus": true
}
}
```
`after.waitFor` must include at least one of `text`, `selector`, or `urlIncludes`; `timeoutMs` is optional and capped at `20000` so the full act-then-observe chain stays within the default broker request timeout. `after.snapshot` may be `true` for default snapshot options or an object with `mode`, `textLimit`, and/or `limit`. Invalid `after` requests are rejected before the base action runs.
If the base action succeeds but an `after` observation fails, the response still includes the base action result and sets `after` to `{ "ok": false, "error": "..." }`.
## Snapshot Modes And Refs
Default compact snapshots are designed to reduce model-context usage while preserving browser automation. A compact snapshot looks like:
```json
{
"title": "Example Domain",
"url": "https://example.com/",
"mode": "compact",
"elements": [{ "ref": "h1", "role": "link", "label": "Learn more" }],
"omittedElements": 0,
"textPreview": "Example Domain ...",
"textBytesOmitted": 0,
"regions": []
}
```
Use full mode only when you need the legacy verbose element metadata:
```json
{ "mode": "full", "tabId": 123 }
```
Use visible mode for viewport-bound work, virtualized pages, and click-coordinate planning:
```json
{ "mode": "visible", "sessionTabId": "tab-1" }
```
To read long page content (for example API docs), raise `textLimit` instead of using broker scripts or CDP workarounds:
```json
{ "mode": "full", "textLimit": 100000, "tabId": 123 }
```
Compact mode honors `textLimit` too; body text is returned in `textPreview` (there is no `text` field in compact mode). When `textBytesOmitted` is greater than zero, increase `textLimit` or scroll the page and snapshot again only if content is lazy-loaded below the fold.
When a visible genuinely-modal dialog is open (`aria-modal="true"`, or a `<dialog>` shown with `showModal()` — the `open` attribute alone is not enough), compact snapshots set `scopeApplied` to `dialog` and read that dialog instead of the page behind it. `role="alertdialog"` uses the same modal predicate; without `aria-modal` or `:modal` it does not take scope. A top-document snapshot does not see a modal inside an iframe — pass that frame's `documentId`. The same `scope`, `ignoreRoles`, and `mode` escape hatches listed on `snapshot` still apply.
Refs are per-document in-memory IDs (`h...`) assigned from element identity, not output order. They remain stable across DOM insertion/reorder in the same document, and `click` / `type` resolve through the content script's ref store. Refs can collide between frame documents, so retain the result's `documentId` and pass it with later iframe actions. Navigating to a different page loads a new document, so old refs are expected to fail cleanly; take a fresh snapshot after navigation or major page changes. The ref store prunes disconnected, expired, and over-cap entries, and removes stale `data-cbc-ref` attributes so pruned refs cannot be reused accidentally.
## Tab Sessions
Prefer `claim_tab` before multi-step browser work:
```json
{ "tabId": 123 }
```
The returned `sessionTabId` can be passed to `snapshot`, `navigate`, `activate_tab`, `click`, `type`, `scroll`, `query_elements`, `extract_elements`, `screenshot`, `wait_for`, and related page tools. If a session has a current claim, page actions without an explicit `tabId` or `sessionTabId` route to that claim. If no claim exists, legacy active-tab fallback remains.
Claims are advisory MCP routing state only. They do not stop the user from changing, closing, or navigating a tab. Use `release_tab` or `finalize_tabs` when a task is complete; neither tool closes browser tabs.
## Development Checks
```bash
npm test
npm run build
cbctl doctor
# or: node dist/cli/main.js doctor
npm run benchmark:compact-snapshots
npm audit
```
`npm run benchmark:snapshots` is an alias for the same compact-vs-full benchmark. The benchmark prints compact bytes, full bytes, and reduction percentage; compact mode should stay at least 50% smaller on the dense fixture.
After editing files under `extension/`, run `npm run build`, run `cbctl setup`, then reload the unpacked extension on `chrome://extensions` before running browser e2e checks. After adapter/server changes, rebuild and restart the MCP host too. A stale loaded background service worker or tool catalog can keep serving older behavior; `browser_status` should report `adapter.registeredToolCount: 30`, extension protocol version `7`, and the `cdp-trusted-input` feature marker when both sides are current.
## Limitations
- This is a prototype with a shared local token, not multi-user authentication.
- Browser tool calls are serialized globally at the broker.
- Content scripts use DOM snapshots, not the full Chrome accessibility tree.
- Refs are document-scoped in-memory handles. Run `snapshot` again after navigation, reloads, major DOM changes, or stale-ref errors.
- Visible screenshots are viewport-only. Capturing an inactive tab may activate it because Chrome MV3 captures the visible tab in a window.
- Chrome screenshot capture requires `<all_urls>` or `activeTab`. This project requests optional `<all_urls>` as a host permission only for wildcard screenshots. If `screenshot` reports that this permission is missing, reload the extension after manifest updates, open the popup, save settings, and grant the prompt.
- `keypress` and `click_at` use DOM events unless the tab is CDP-attached. After `cdp_attach`, they use trusted CDP input (`isTrusted: true`). They still do not guarantee privileged browser or OS shortcuts.
- Console logs are captured only after content script injection and are bounded.
- Resource summaries are counts from the Performance API only; request headers, cookies, storage, history, bookmarks, and downloads are intentionally not exposed. Response bodies are not available through `page_status`.
- Browser history, bookmark, download, and cookie tools are intentionally not exposed.
## Security
- No default token is accepted. Set `CHROME_BROWSER_CONTROL_TOKEN` to a high-entropy URL-safe value for both the broker and MCP adapter, then paste the same value into the extension popup.
- The broker binds only to loopback hosts: `127.0.0.1`, `localhost`, or `::1`.
- The extension only connects to `ws://127.0.0.1`, `ws://localhost`, or `ws://[::1]` with an optional port.
- Page access is limited by allowed origins configured in the popup. Use explicit entries such as `https://example.com`, or enter `*` to allow all normal `http://` and `https://` web pages. Tabs and page actions outside the configured scope are blocked.
- Allowed-origin checks happen in the extension background before content actions, screenshots, and tab claims.
- Password-like and OTP fields are detected by input type, autocomplete, names, IDs, labels, and placeholders. `type` blocks them unless `force=true`.
- Optional `CHROME_BROWSER_CONTROL_EXTENSION_ID` pins the broker to one installed extension ID.
- Trusted CDP input is opt-in and off by default. The `debugger` permission is required in the manifest, so Chrome shows a warning when the extension loads. The extension always holds debugger capability; it cannot be revoked without uninstalling. Enable the popup `enableCdp` toggle, claim the tab, then call `cdp_attach`. The debugging banner on attached tabs is expected. Attach is gated by that stored flag plus a claimed tab plus the existing allowed-origins list; the method allowlist is `Input.dispatchMouseEvent`, `Input.dispatchKeyEvent`, `Network.enable`, `Network.disable`, and `Network.getResponseBody`. Response-body reads are a separate deny-by-default allowlist in the popup; that list does not accept `*`. Restricted categories (banking, wallet, password manager) are refused even when listed. This is an opt-in debugging tier for someone who understands what they pointed it at. It is not safe to switch on and leave on. The socket fails closed on service-worker suspension, navigation away from an allowed origin, DevTools eviction, the popup toggle turning off, and Allowed Origins changes that leave the tab outside the list. Hydration never treats another debugger's attached target as this extension. The MCP adapter does not open a raw CDP socket.
Never bind the broker to a non-loopback interface or commit tokens, local config files, logs, or personal setup notes.
## Maintainer publish
First public npm releases are **manual**. Maintainers follow [`docs/publish-checklist.md`](docs/publish-checklist.md). Do not add auto-publish-on-push or long-lived npm tokens in CI for the default release path.
TDQS
Scored across 30 tools
Several tools overlap significantly: snapshot, visible_snapshot, query_elements, extract_elements, and page_status all provide ways to inspect page content/state; click, click_at, and perform_actions (with click steps) overlap in interaction; browser_status and page_status both report status but with different focus. The descriptions are detailed but the high-level purposes blur together.
Tool names are predominantly verb_noun (e.g., list_tabs, navigate, click_at), but there is inconsistency in verb style: some use explicit verbs like 'browser_status' (noun-only), 'cdp_attach' (abbreviation), 'snapshot' (noun-only), and 'keypress' (verb-noun but single word). Also 'extract_feed_posts' is more domain-specific than the rest.
30 tools is on the high end for a browser control server. While the domain is complex, many tools feel like slight variations of each other (e.g., snapshot vs visible_snapshot), making the count feel bloated. The server could be consolidated to around 20 tools without losing functionality.
The tool set covers well the lifecycle of controlling a browser: claiming tabs, navigating, interacting, snapshotting, waiting, extracting, and CDP network inspection. Missing basic operations like page reload, back/forward, or download management are minor gaps that agents can work around.