---
name: Hive Browser Automation
slug: hive-browser-automation
category: Automation
description: Hive Browser Automation drives a real Chrome instance through the Beeline extension and CDP using the hive-browser CLI, covering lifecycle, screenshots, viewport-fraction coordinates, shadow-DOM inputs, and CSP quirks.
github: "https://github.com/aden-hive/hive/tree/main/core/framework/skills/_default_skills/browser-automation"
language: Python
stars: 10907
forks: 5668
install: "npx degit https://github.com/aden-hive/hive/tree/main/core/framework/skills/_default_skills/browser-automation ~/.claude/skills/browser-automation"
installs_to: ~/.claude/skills/browser-automation
source_path: core/framework/skills/_default_skills/browser-automation/SKILL.md
collection_size: 24
category_size: 1523
collection_url: "https://dirskills.com/collections/aden-hive/hive"
added: 2026-08-15T06:50:11.066Z
last_synced: 2026-08-15T06:50:11.066Z
canonical_url: "https://dirskills.com/skills/hive-browser-automation"
---

# Hive Browser Automation

Hive Browser Automation drives a real Chrome instance through the Beeline extension and CDP using the hive-browser CLI, covering lifecycle, screenshots, viewport-fraction coordinates, shadow-DOM inputs, and CSP quirks.

**Install:**

```bash
npx degit https://github.com/aden-hive/hive/tree/main/core/framework/skills/_default_skills/browser-automation ~/.claude/skills/browser-automation
```

## README

# GCU Browser Automation

All GCU browser automation drives a real Chrome instance through the Beeline extension and Chrome DevTools Protocol (CDP). You drive it **from the terminal**: every command is `hive-browser <command> ... --json`, run through `terminal_exec`. Always pass `--json` so the result is machine-readable. That means clicks, keystrokes, and screenshots are processed by the actual browser's native hit testing, focus, and layout engines — **not** a synthetic event layer. Understanding this unlocks strategies that make hard sites easy.

## Browser lifecycle & recovery — read this before "fixing" anything

The bridge attaches to the **user's already-running Chrome** via the extension. The browser is not yours: it holds the user's logged-in sessions and other agents' work, and the runtime — not you — owns the connection to it. Consequences:

- **There is nothing to "start."** If no browser is connected, `hive-browser setup --json` tells you and gives the user install steps. Launching Chrome yourself (any `google-chrome`/`chromium` command, `--remote-debugging-port`, `--user-data-dir`, headless flags) is forbidden and blocked — it opens a browser the bridge can't see, often under the wrong profile.
- **There is no situation where killing the browser helps.** Never use terminal tools to `kill`/`pkill`/`killall` Chrome, the bridge, or gcu processes. These commands are blocked, and attempting them breaks every agent sharing the connection. The same applies to the Hive desktop app and `bridge_host`.
- **A timeout is NOT a stuck browser.** All `hive-browser` commands share one transport across all agents; one slow call (a heavy `hive-browser evaluate` on a big page) can make YOUR calls time out while the browser is perfectly healthy. The timeout message tells you whether the server passed its liveness check and whether recovery is already running — believe it.
- **Allowed recovery, in order:** (1) wait ~30s and retry ONCE, with a smaller/simpler request — e.g. split a page-wide `hive-browser evaluate` sweep into chunked queries; (2) close YOUR OWN tabs with `hive-browser tab close <T> --json` and reopen; (3) report the failure (`report_to_parent` or your reporting channel) and move on to work that doesn't need the browser. Escalate to the user; never to the process table.
- Keep heavy `hive-browser evaluate` scripts cheap: avoid `innerText` over thousands of nodes (each read forces layout). Prefer `textContent`, scope the selector, and paginate the sweep.

## Targeting a specific Chrome profile / account

If your machine has more than one Chrome profile connected (different logged-in accounts), **say which one to act in** — if you omit it, the bridge falls back to the first-connected profile, which may be the wrong account. Pass `--browser-profile <label>` to `hive-browser open` (and `hive-browser navigate` / `hive-browser script`):

- **See every connected profile** in `hive-browser status --json` / `hive-browser setup --json` — both return a `connected_profiles` list of `{label, is_default, starred}` for ALL connected Chrome profiles (not just the one you're using). That's how you discover the labels. (`hive-browser status` reflects ALL connections; don't conclude "only one profile" from the single profile label of your own context.)
- The label is a **connected profile label** — the name shown in that profile's Hive extension side panel (or its auto 3-word id). If your task assigns you a profile (e.g. *"your profile is `acct-jpn`"*), pass exactly that.
- Every command's JSON echoes the profile it **actually** used. Check it: if it doesn't match what you intended, you opened the wrong account. Stop and fix the label (don't proceed).
- A label that isn't connected fails fast with the list of connected labels — bind to one of those.
- With one profile connected (or one starred default), you can omit it. With several connected and no star, omitting it uses the **first-connected** profile — fine for single-account work, risky for multi-account, so pass the label when the account matters.

## Working on LinkedIn?

For ANY LinkedIn flow, load `hive.linkedin-core` first — it owns the auth check, rate limits, stop protocol, reply-text policy, and DOM gotchas every LinkedIn script depends on. Then load the capability skill for the task: `hive.linkedin-discovery` (scans / People search), `hive.linkedin-messaging` (`lk_send_to_message_url`, `reply`, inbox), `hive.linkedin-connect` (`lk_send_invite`, post comments), or `hive.linkedin-sales-navigator` (premium search + InMail).

## Coordinates

Every `hive-browser interact` action that takes a `--coordinate` — and every command that returns one — operates in **fractions of the viewport (0..1 for both axes)**. Read a target's proportional position off `hive-browser screenshot` — "this button is about 35% from the left and 20% from the top" → pass `--coordinate 0.35,0.20`. Rect-returning commands (`hive-browser page shadow-query` and the `rect` inside `focused_element`) also return fractions. The CLI converts to CSS pixels internally before dispatching to Chrome.

```
hive-browser screenshot --json                                      → image + cssWidth/cssHeight in meta
hive-browser interact --action left_click --coordinate x,y --json   → x, y are fractions 0..1
hive-browser interact --action hover --coordinate x,y --json        → fractions
hive-browser interact --action key --coordinate x,y --text k --json → fractions
hive-browser page shadow-query "<selector>" --json → rect           → rect.cx / rect.cy are fractions
```

**Exception for zoomed elements:** pages that use `zoom` or `transform: scale()` on a container (LinkedIn's `#interop-outlet`, some embedded iframes) render in a scaled local coordinate space. `getBoundingClientRect` there may not match CDP's hit space. Prefer `hive-browser page shadow-query` (which handles the math and returns fractions) or visually pick coordinates from a screenshot. Avoid raw `hive-browser evaluate` + `getBoundingClientRect()` for coord lookup — that returns CSS px and will be wrong when fed to a `--coordinate`.

## Screenshot + coordinates is shadow-agnostic — prefer it on shadow-heavy sites

Start with `hive-browser page snapshot` when you need to inspect the page structure or find ordinary controls. If the snapshot does not show the thing you need, shows stale or misleading refs, or cannot prove where a visible target is, take `hive-browser screenshot` and use the screenshot + coordinate path. This is especially useful on sites that use Shadow DOM heavily.

Why:

- **CDP hit testing walks shadow roots natively.** `hive-browser interact --action left_click --coordinate x,y --json` routes through Chrome's native hit tester, which traverses open shadow roots automatically. You don't need to know the shadow structure.
- **Keyboard dispatch follows focus** into shadow roots. After a click focuses an input (even one three shadow levels deep), `hive-browser interact --action key ... --json` with no `--selector` dispatches keys to `document.activeElement`'s computed focus target.
- **Screenshots render the real layout** regardless of DOM implementation.

Whereas `wait_for_selector` and a `selector`-targeted `left_click` / `type` all use `document.querySelector` under the hood, which **stops at shadow boundaries**. They cannot see elements inside shadow roots. For shadow-DOM inputs, use a `type` action with no selector after focusing via a `coordinate` click.

### Recommended workflow on shadow-heavy sites

1. `hive-browser screenshot --json` → JPEG. **The image is attached to your context automatically on your next turn — do NOT `attach_file` / read the `saved_to` path; that is redundant and wasteful.** The result JSON carries a `saved_to` path (read it ONLY if, in a later turn, no image actually appeared); meta includes `cssWidth`/`cssHeight` for reference.
2. Identify the target visually → estimate its proportional position `[fx, fy]` where each is in `0..1`.
3. `hive-browser interact --action left_click --coordinate fx,fy --json` → the CLI converts to CSS px and dispatches; CDP native hit testing focuses the element. **The result includes `focused_element: {tag, id, role, contenteditable, rect, inFrame?, ...}`** — use it to verify you actually focused what you intended. `rect` is in fractions (same space as your input). When focus is inside a same-origin iframe, the descriptor reports the inner element and adds `inFrame: [...]` breadcrumbs.
4. `hive-browser interact --action type --text "..." --json` with no `--selector` → inserts text into `document.activeElement` (traverses into same-origin iframes automatically). Shadow roots, iframes, Lexical, Draft.js, ProseMirror all just work. Pass `--selector` instead when you have a reliable CSS selector for a light-DOM element.
5. Verify via `hive-browser screenshot` OR `hive-browser evaluate` reading a known-reachable marker (e.g. check that the Send button's `aria-disabled` flipped to `false`).

### The click→type loop (canonical pattern)

1. Run `hive-browser interact --action left_click --coordinate x,y --json` to click the target element.
2. Check the `focused_element` field in the result — it tells you what actually received focus (tag, id, role, contenteditable, rect).
3. If the focused element is editable, run `hive-browser interact --action type --text "..." --json` to insert text. Verify the text took effect — prefer checking the underlying `.value` / `innerText` via `hive-browser evaluate` or confirming the submit button enabled. A screenshot alone can mislead: narrow input boxes visually clip long text, so only a portion may appear on screen even though the full string was accepted.
4. If it is NOT editable, your click landed on the wrong thing — refine coordinates and retry. Do NOT reach for `hive-browser evaluate` + `execCommand('insertText')` or shadow-root traversals. The problem is the click target, not the typing method.

A `--selector`-based `left_click` also returns `focused_element`, so the same check works whether you clicked by selector or coordinate.

### Empirically verified (2026-04-11)

Tested against `https://www.reddit.com/r/programming/` whose search input lives at:

```
document > reddit-search-large [shadow]
         > faceplate-search-input#search-input [shadow]
         > input[name="q"]
```

### Shadow-piercing selectors

When you DO want a selector-based approach and know the shadow structure, `hive-browser page shadow-query` supports `>>>` shadow-piercing syntax:

```
hive-browser page shadow-query "reddit-search-large >>> #search-input" --json
hive-browser page shadow-query "#interop-outlet >>> #ember37 >>> p" --json
```

Returns the element's rect as **fractions of the viewport** (feed `rect.cx` / `rect.cy` straight into a `--coordinate`). Remember: a `type` action's `--selector` and `--wait-for-selector` do **not** support `>>>` — only `page shadow-query` does.

## Navigation and waiting

### The basics

```
hive-browser navigate <url> --wait-until load --json   # load | domcontentloaded | networkidle
hive-browser interact --action wait --wait-for-selector "h1" --timeout-ms 2000 --json
hive-browser interact --action wait --wait-for-text "Some text" --timeout-ms 2000 --json
hive-browser reload --json
hive-browser evaluate --js 'history.back()' --json     # back/forward via history API
```

All return real URLs and titles. On a fast page `navigate --wait-until load` returns in sub-second. A `wait` action with `--wait-for-selector` / `--wait-for-text` typically resolves in single-digit milliseconds on elements already in the DOM.

### Timing expectations (measured against real sites)

| Site                     | Navigate load time |
| ------------------------ | ------------------ |
| wikipedia.org            | 200–500 ms         |
| reddit.com               | 1.5–2 s            |
| x.com/twitter            | 1.2–1.6 s          |
| linkedin.com (logged in) | 4–5 s              |


### After navigate, always let SPA hydrate

Even after `--wait-until load`, React/Vue SPAs often render their real chrome in a second pass. Add `await sleep(2)` to `await sleep(3)` before querying for site-specific elements. Otherwise a `wait` action will fail on elements that do exist moments later.

### Reading pages efficiently

- **Prefer `hive-browser page snapshot` over `hive-browser page text "body"`** — returns a compact ~1–5 KB accessibility tree vs 100+ KB of raw HTML.
- State-changing `hive-browser interact` actions (`left_click`, `type`, `scroll`) wait 0.5 s for the page to settle after a successful action, then attach a fresh accessibility snapshot under the `snapshot` key of their result. Use it to decide your next action — do NOT run `hive-browser page snapshot` separately after every action. Tune the capture via `--auto-snapshot-mode`: `simple` (the default — trims unnamed structural nodes), `default` (full tree), `interactive` (only controls — tightest token footprint), or `off` to skip the capture entirely (useful when batching several interactions and you don't need the intermediate trees). Run `hive-browser page snapshot` explicitly only when you need a newer view or a different mode than what was auto-captured.
- Complex pages (LinkedIn, Twitter/X, SPAs with virtual scrolling) can have DOMs that don't match what's visually rendered — snapshot refs may be stale, missing, or misaligned with visible layout. Try the available snapshot first; when the target is not present in that snapshot or visual position matters, switch to `hive-browser screenshot` to orient yourself.
- Only fall back to `hive-browser page text` for extracting specific small elements by CSS selector.

## Typing and keyboard input

### ALWAYS click before typing into rich-text editors

**The single most common "looks like it worked but send button stays disabled" failure.** If you're typing into a modern editor (X/Twitter's Draft.js compose, LinkedIn's post composer, Reddit's comment box, Gmail compose, Slack, Discord, Notion, Monaco, any `contenteditable`), **click the input area first** — a `left_click` action with a `coordinate` or a `selector` — **before you type**.

Why this is necessary:

- **React / Vue controlled components** don't trust JS-sourced `.focus()`. React uses event delegation and watches for _native_ pointer/focus events — a `click` dispatched via CDP fires the real `pointerdown`/`pointerup`/`click`/`focus` sequence that React listens to, and updates its internal state. A JS-only `.focus()` sets `document.activeElement` but the framework's controlled state doesn't see it.
- **Draft.js** (X/Twitter compose) and **Lexical** (Gmail, LinkedIn DMs) use contenteditable divs with immutable editor state. They only enter "edit mode" after a real click on the editor surface. Typing at them without clicking routes keys to `document.body` or gets silently discarded.
- **Send/submit buttons are bound to framework state**, not DOM state. They're typically `disabled={!hasRealContent}` where `hasRealContent` is computed from React/Vue/Svelte state. The input field can have characters in the DOM but the button stays disabled because the framework never saw a real input event.

The symptom is always the same: **you type, the characters appear visually, and the send button doesn't enable**. The agent then clicks send anyway, nothing happens, and it thinks the post failed.

### Safe "click-then-type-then-verify" pattern

1. **Focus** the real element via a real click (not JS `.focus()`). Use `hive-browser page shadow-query "<selector>" --json` to get coordinates, then `hive-browser interact --action left_click --coordinate cx,cy --json`. Wait ~0.5 s for the editor to open and focus to settle.

2. **Type** the text with `hive-browser interact --action type --text "..." --json` — pass `--selector` for light-DOM inputs, or omit it for shadow-DOM / already-focused inputs. It uses CDP `Input.insertText` by default, the most reliable method for rich editors (Lexical, Draft.js, ProseMirror). Wait ~500 ms for framework state to commit.

3. **Verify** the submit button is enabled before clicking it. Use `hive-browser evaluate` to check the button's `disabled` or `aria-disabled` attribute. Do NOT trust that typing worked — always check state.

   **Partial visibility is fine.** Small single-line inputs, chat boxes with fixed width, and search fields commonly clip or truncate long text visually — only the tail or head may be shown on screen. Don't treat that as failure. What matters is that the framework accepted the input: the submit button enabled, or `element.value` / `innerText` read via `hive-browser evaluate` contains the full string. If the visible pixels don't match what you typed but the button is enabled and the underlying value is correct, typing succeeded — proceed.

4. **Only click send if the button is enabled.** If the button is still disabled, try the recovery dance: click the textarea again, press `End`, press a space, press `Backspace` — this forces React to recompute `hasRealContent`. Then re-check the button state.

### Why the `type` action uses `Input.insertText` by default

`Input.insertText` commits text as if IME just committed it, bypassing the keyboard event pipeline. It works on every rich editor tested — Lexical (LinkedIn DMs, Gmail), Draft.js (X compose), ProseMirror (Reddit), Monaco, plain `contenteditable` — and is what Playwright uses under the hood.

Per-character `Input.dispatchKeyEvent` looks equivalent but fails on editors that route insertion through their own `beforeinput` state machine: the keys arrive, no text appears. This left LinkedIn's composer empty (Send disabled) in the 2026-04-11 run.

For per-keystroke dispatch (autocomplete testing, key-event-driven code editors), pass `--no-use-insert-text` to fall back to the `keyDown/keyUp` path. Pacing is fixed at 1ms.

### Neutralizing `beforeunload` draft dialogs

When a composer has unsent text and you try to navigate away or close the tab, sites like LinkedIn pop a native "You have an unsent message, leave?" confirm dialog via `window.onbeforeunload`. Your automation hangs waiting on the dialog — `hive-browser tab close` and `hive-browser navigate` both time out.

**Strip the handler via `hive-browser evaluate` before navigating** (heredoc into `--js -` keeps the quote-heavy script intact; `--js @file.js` works too):

```
hive-browser evaluate --json --js - <<'JS'
(function(){
  window.onbeforeunload = null;
  window.addEventListener('beforeunload', function(e){
    e.stopImmediatePropagation();
  }, true);
  return true;
})()
JS
# Now hive-browser navigate / tab close work without hitting a confirm
```

Always include an equivalent cleanup block in any script that types into a compose UI — without it, a script crash mid-type leaves the tab in an unusable state with the draft modal blocking every subsequent automation call.

### Verified site-specific quirks

| Site                                                 | Editor                                                 | Workaround                                                                                                                                                                                                                             |
| ---------------------------------------------------- | ------------------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **X / Twitter** compose                              | Draft.js                                               | Click `[data-testid='tweetTextarea_0']` first, then a `type` action (default `--use-insert-text` handles Draft.js cleanly). First 1-2 chars may be eaten on the per-char fallback — accept truncation or prepend a throwaway char. Verify `[data-testid='tweetButton']` 
