# Browser Hybrid — Specification

**Chrome DevTools with accessibility-tree semantics for browser automation.**

Use your existing authenticated Chrome sessions for browser automation, with agent-browser-style accessibility tree element targeting.

---

## Overview

Browser Hybrid (BH) connects to Chrome via the Chrome DevTools Protocol (CDP), enabling browser automation that preserves your logged-in sessions (Gmail, GitHub, banking, SSO). Unlike Selenium/Playwright which spin up fresh browser instances, BH uses Chrome you already have running with `--remote-debugging-port=9222`.

### Key Properties

| Property | Value |
|---|---|
| Language | Python ≥3.10 |
| Dependencies | `websockets >= 12.0` only (stdlib + websockets) |
| Package | `src/browser_hybrid/` |
| Entry point | `browser-hybrid` CLI / `from browser_hybrid import Browser` |
| License | MIT |

---

## Architecture

```
┌─────────────────────────────────────────────────────┐
│                  Browser Hybrid                     │
├─────────────────────────────────────────────────────┤
│  Python API (browser.py)  │  CLI (cli.py)           │
├─────────────────────────────────────────────────────┤
│              CDP Client Layer                        │
│  HTTP API (/json/*)      │  WebSocket API (CDP)    │
│  Tab management          │  DOM/A11y/Page cmds     │
└─────────────────────────────────────────────────────┘
                          │
                          ▼
              ┌────────────────────────┐
              │  Chrome DevTools        │
              │  localhost:9222        │
              │  (your Chrome)         │
              └────────────────────────┘
```

### Chrome Requirement

Chrome must be running with remote debugging enabled:

```bash
# macOS
/Applications/Google\ Chrome.app/Contents/MacOS/Google\ Chrome \
  --remote-debugging-port=9222 \
  --user-data-dir="$HOME/Library/Application Support/Google/Chrome-Debug"

# Linux
google-chrome --remote-debugging-port=9222 --user-data-dir="$HOME/chrome-debug"

# Windows
start chrome --remote-debugging-port=9222 --user-data-dir="%LOCALAPPDATA%\Google\Chrome-Debug"
```

Or use the provided `chrome-debug.sh` helper script.

---

## API Reference

### `Browser(host="localhost", port=9222, timeout=10.0)`

Main browser automation class. Connects to Chrome at the given host/port.

**Raises:** `ConnectionError` if Chrome DevTools is not reachable.

```python
from browser_hybrid import Browser

browser = Browser()                    # Connect to localhost:9222
browser = Browser(port=9223)           # Custom port
```

---

## Connection Management

### `browser.is_connected() -> bool`
Returns `True` if connected to Chrome, `False` otherwise.

```python
if browser.is_connected():
    print("Ready to automate")
```

### `browser.wait_for_chrome(timeout: float = 30.0) -> bool`
Wait for Chrome to become available. Useful when Chrome is still starting up.

```python
# Chrome might not be ready yet
if browser.wait_for_chrome(timeout=60.0):
    print("Chrome is ready")
else:
    print("Chrome did not start in time")
```

### `browser.close() -> None`
Close the browser connection. Does NOT close Chrome itself, just disconnects.

```python
browser.close()
```

---

## Tab Management

### `browser.list_tabs() -> List[Tab]`
Returns all open browser tabs (excludes service workers).

```python
tabs = browser.list_tabs()
for tab in tabs:
    print(tab.id, tab.title, tab.url)
```

### `browser.new_tab(url: str = "about:blank") -> Tab`
Opens a new tab and navigates to URL. Sets as current tab.

```python
tab = browser.new_tab("https://gmail.com")
print(f"Opened {tab.url}")
```

### `browser.activate_tab(tab_id: str) -> bool`
Brings tab to front (focuses it).

```python
tabs = browser.list_tabs()
browser.activate_tab(tabs[0].id)
```

### `browser.close_tab(tab_id: Optional[str] = None) -> bool`
Closes specified tab or current tab if no ID given.

```python
browser.close_tab("abc123")  # Close by ID
browser.close_tab()           # Close current tab
```

### `browser.current_tab() -> Optional[Tab]`
Returns the currently active tab, or `None` if no tab is set.

```python
tab = browser.current_tab()
if tab:
    print(f"Active: {tab.title}")
```

### `browser.get_version() -> BrowserInfo`
Returns Chrome version and protocol info.

```python
info = browser.get_version()
print(info.browser)            # Chrome/120.0.0.0
print(info.protocol_version)   # 1.3
```

---

## Navigation

### `browser.navigate(url: str, tab: Optional[Tab] = None) -> str`
Navigates current tab (or specified tab) to URL. Returns frameId.

```python
frame_id = browser.navigate("https://github.com")
```

### `browser.goto(url: str) -> Tab`
Convenience: creates new tab, navigates, waits 0.5s for initial load. Returns Tab.

```python
tab = browser.goto("https://news.ycombinator.com")
```

---

## Wait Conditions

### `browser.wait_for(selector: Optional[str] = None, text: Optional[str] = None, url: Optional[str] = None, timeout: float = 10.0) -> bool`
Polls until element selector, text content, or URL appears. Returns `True` if found, `False` on timeout.

```python
browser.goto("https://example.com")
browser.wait_for(text="Sign in", timeout=15.0)
browser.wait_for(selector="button.submit")
browser.wait_for(url="https://example.com/success")
```

### `browser.wait_for_selector_visible(selector: str, timeout: float = 10.0) -> bool`
Wait for element to be visible (not hidden).

```python
browser.wait_for_selector_visible("#modal", timeout=5.0)
```

### `browser.wait_for_selector_hidden(selector: str, timeout: float = 10.0) -> bool`
Wait for element to be hidden or removed.

```python
browser.wait_for_selector_hidden("#loading-spinner")
```

### `browser.wait_for_load_state(state: str = "load", timeout: float = 30.0) -> bool`
Wait for page load state. States: `"load"`, `"domcontentloaded"`, `"networkidle"`.

```python
browser.wait_for_load_state("networkidle")  # Wait for network to settle
browser.wait_for_load_state("domcontentloaded")  # DOM ready
```

### `browser.wait_for_navigation(timeout: float = 10.0) -> bool`
Wait for navigation to complete. Captures current URL and waits for it to change.

```python
browser.click_by_text("Next page")
browser.wait_for_navigation()
```

### `browser.wait_for_url(url: str, timeout: float = 10.0) -> bool`
Wait for specific URL (exact match or substring).

```python
browser.wait_for_url("https://example.com/success")
browser.wait_for_url("/checkout")  # Substring match
```

### `browser.wait_for_function(script: str, timeout: float = 10.0) -> Any`
Wait for JavaScript expression to return truthy value.

```python
# Wait for element count > 5
browser.wait_for_function("document.querySelectorAll('.item').length > 5")

# Wait for custom condition
browser.wait_for_function("window.myApp && window.myApp.isReady")
```

---

## Accessibility Tree

### `browser.accessibility_tree(tab: Optional[Tab] = None) -> AXNode`
Returns the full accessibility tree for the current tab (or specified tab). Tree uses Chrome's `nodeId` as `ref` for element targeting.

```python
tree = browser.accessibility_tree()
print(tree.to_tree_str())

# Find elements
links = tree.find_by_role("link")
buttons = tree.find_by_role("button")
headings = tree.find_by_role("heading")
inputs = tree.find_by_role("textbox")

# Find by text
matches = tree.find_by_name("Submit")

# Find by ref
node = tree.find("42")
```

**Output format** (agent-browser compatible):
```
- RootWebArea "Page Title" [ref=1]
    /url: https://example.com/
  - heading "Welcome" [ref=2] [level=1]
  - link "Sign in" [ref=3]
      /url: /login
  - textbox "Email" [ref=4]
```

### `browser.snapshot(tab: Optional[Tab] = None) -> str`
Alias for `accessibility_tree().to_tree_str()`. Returns human-readable tree string.

```python
print(browser.snapshot())
```

---

## Click Actions

### `browser.click(ref: str, tab: Optional[Tab] = None) -> bool`
Click element by accessibility ref (nodeId).

```python
browser.click("42")  # Click element with ref=42
```

### `browser.click_by_text(text: str, tab: Optional[Tab] = None) -> bool`
Click first element matching text. Searches links, buttons, then any clickable element.

```python
browser.click_by_text("Sign in")
browser.click_by_text("Submit")
```

**Behavior:**
1. Search `<a>` elements by innerText/textContent
2. Search `<button>` and `<input type="button/submit">` by text
3. Search any element with exact text match and `.click()` method

Returns `True` if click was dispatched, `False` if no matching element found.

---

## Keyboard & Typing

### `browser.type_text(ref: str, text: str, tab: Optional[Tab] = None) -> bool`
Type text into element identified by ref. Falls back to first input if ref lookup fails.

```python
browser.type_text("42", "hello world")
```

### `browser.type_by_name(name: str, text: str, tab: Optional[Tab] = None) -> bool`
Type text into element by accessible name (label, placeholder, or aria-label).

```python
browser.type_by_name("Email", "user@example.com")
browser.type_by_name("Password", "secret123")
```

### `browser.type_slowly(ref: str, text: str, delay: float = 0.05, tab: Optional[Tab] = None) -> bool`
Type text character by character with delay between keystrokes. Useful for inputs that validate on each keystroke.

```python
browser.type_slowly("42", "slowly typed text", delay=0.1)
```

### `browser.press_key(key: str, modifiers: Optional[List[str]] = None, tab: Optional[Tab] = None) -> bool`
Press a key or key combination.

```python
browser.press_key("Enter")
browser.press_key("Tab")
browser.press_key("Escape")

# Key combinations
browser.press_key("a", modifiers=["Control"])  # Ctrl+A (select all)
browser.press_key("c", modifiers=["Control"])   # Ctrl+C (copy)
browser.press_key("v", modifiers=["Control"])   # Ctrl+V (paste)
browser.press_key("Enter", modifiers=["Shift"]) # Shift+Enter

# Special keys
browser.press_key("ArrowDown")
browser.press_key("PageDown")
browser.press_key("Home")
browser.press_key("Delete")
browser.press_key("Backspace")
```

**Valid modifiers:** `"Control"`, `"Shift"`, `"Alt"`, `"Meta"`

---

## Mouse Actions

### `browser.hover(ref: str, tab: Optional[Tab] = None) -> bool`
Hover over element by accessibility ref. Dispatches mouseover and mousemove events.

```python
tree = browser.accessibility_tree()
button = tree.find_by_role("button", name="Menu")[0]
browser.hover(button.ref)
```

---

## Form Filling

### `browser.fill_form(fields: Dict[str, str], tab: Optional[Tab] = None) -> Dict[str, bool]`
Fill multiple form fields at once. Keys are label text, placeholder, or input name/id. Returns dict mapping field names to success status.

```python
results = browser.fill_form({
    "Email": "user@example.com",
    "Password": "secret123",
    "Remember me": ""  # Checkbox will be checked
})
print(results)  # {"Email": True, "Password": True, "Remember me": True}
```

**Behavior:**
1. Find `<label>` elements whose text contains the key → use `for` attribute or child input
2. Find input by `name` or `id` matching the key
3. For empty values, toggle checkboxes

---

## Content & Screenshot

### `browser.screenshot(path: str, tab: Optional[Tab] = None) -> str`
Capture PNG screenshot and save to path. Returns path.

```python
browser.screenshot("/tmp/page.png")
browser.screenshot("/tmp/gmail_inbox.png")
```

### `browser.get_html(tab: Optional[Tab] = None) -> str`
Returns page `outerHTML`.

```python
html = browser.get_html()
print(len(html), "bytes")
```

### `browser.evaluate(script: str, tab: Optional[Tab] = None) -> Any`
Execute JavaScript and return result (must be JSON-serializable).

```python
title = browser.evaluate("document.title")
count = browser.evaluate("document.querySelectorAll('.item').length")
url = browser.evaluate("window.location.href")
```

---

## Cookies

### `browser.get_cookies(urls: Optional[List[str]] = None, tab: Optional[Tab] = None) -> List[Dict]`
Get all cookies, or cookies for specific URLs.

```python
# Get all cookies
cookies = browser.get_cookies()
for cookie in cookies:
    print(f"{cookie['name']}: {cookie['value']}")

# Get cookies for specific URLs
cookies = browser.get_cookies(urls=["https://example.com"])
```

### `browser.set_cookies(cookies: List[Dict], tab: Optional[Tab] = None) -> None`
Set one or more cookies.

```python
# Set a single cookie
browser.set_cookies([{
    "name": "session",
    "value": "abc123",
    "domain": ".example.com"
}])

# Set multiple cookies with options
browser.set_cookies([
    {"name": "token", "value": "xyz789", "domain": ".example.com"},
    {"name": "preferences", "value": '{"theme":"dark"}', "domain": ".example.com"}
])

# Set with full options
browser.set_cookies([{
    "name": "session",
    "value": "abc123",
    "domain": ".example.com",
    "path": "/",
    "secure": True,
    "httpOnly": True,
    "expires": 1735689600  # Unix timestamp
}])
```

### `browser.delete_cookies(name: Optional[str] = None, url: Optional[str] = None, tab: Optional[Tab] = None) -> None`
Delete cookies. If no filters specified, deletes all cookies.

```python
# Delete specific cookie
browser.delete_cookies(name="session")

# Delete cookies for a URL
browser.delete_cookies(url="https://example.com")

# Delete all cookies
browser.delete_cookies()
```

---

## Network Interception

### `browser.on_request(callback: Callable, tab: Optional[Tab] = None) -> None`
Register callback for network requests. Called before each request is sent.

```python
def log_request(request):
    print(f"→ {request['request']['method']} {request['request']['url']}")

browser.on_request(log_request)

# Filter API calls
def log_api_calls(request):
    url = request['request']['url']
    if '/api/' in url:
        print(f"API: {url}")

browser.on_request(log_api_calls)
```

### `browser.on_response(callback: Callable, tab: Optional[Tab] = None) -> None`
Register callback for network responses.

```python
def log_response(response):
    status = response['response']['status']
    url = response['response']['url']
    print(f"← {status} {url}")

browser.on_response(log_response)

# Log errors (4xx, 5xx)
def log_errors(response):
    status = response['response']['status']
    if status >= 400:
        print(f"ERROR: {status} {response['response']['url']}")

browser.on_response(log_errors)
```

### `browser.on_request_failed(callback: Callable, tab: Optional[Tab] = None) -> None`
Register callback for failed requests (network errors, DNS failures).

```python
def log_failures(request):
    print(f"FAILED: {request['request']['url']}")
    print(f"Error: {request.get('errorText', 'Unknown')}")

browser.on_request_failed(log_failures)
```

### `browser.clear_interceptors(tab: Optional[Tab] = None) -> None`
Clear all registered network interceptors (on_request, on_response, on_request_failed).

```python
browser.clear_interceptors()
```

---

## Reconnection

Browser Hybrid automatically handles WebSocket disconnections. If the CDP WebSocket disconnects during an operation, the connection is re-established automatically.

### Connection States

- **Connected**: Normal operation
- **Reconnecting**: Temporary disconnect, attempting to reconnect
- **Disconnected**: Chrome not available or closed

### Manual Reconnection Check

```python
if not browser.is_connected():
    if browser.wait_for_chrome(timeout=30.0):
        print("Reconnected!")
    else:
        raise ConnectionError("Chrome not available")
```

---

## Data Models

### `Tab`

```python
@dataclass
class Tab:
    id: str                  # Chrome tab ID
    url: str                 # Current URL
    title: str               # Page title
    type: str                # "page" (always for our purposes)
    web_socket_debugger_url: str  # CDP WebSocket URL
```

### `AXNode`

Accessibility tree node. Refs are Chrome `nodeId` values.

```python
@dataclass
class AXNode:
    ref: str                          # Accessibility ref (nodeId)
    role: str                         # ARIA role (link, button, textbox, etc.)
    name: str                         # Accessible name
    value: Optional[str] = None       # Input value
    url: Optional[str] = None        # Link URL
    level: Optional[int] = None       # Heading level (1-6)
    focused: bool = False              # Is focused
    children: List["AXNode"] = []      # Child nodes
    parent: Optional["AXNode"] = None # Parent node
```

**Methods:**

| Method | Returns | Description |
|---|---|---|
| `find(ref)` | `Optional[AXNode]` | Find node by ref |
| `find_by_role(role)` | `List[AXNode]` | Find all nodes with ARIA role |
| `find_by_name(text)` | `List[AXNode]` | Find nodes where name contains text (case-insensitive) |
| `find_interactive()` | `List[AXNode]` | Find all interactive elements (link, button, textbox, checkbox, radio, combobox, searchbox, etc.) |
| `to_tree_str()` | `str` | Format as agent-browser-compatible tree string |
| `to_dict()` | `dict` | Serialize to dict |

### `BrowserInfo`

```python
@dataclass
class BrowserInfo:
    browser: str                  # Full browser version string
    protocol_version: str          # CDP protocol version
    user_agent: str                # User-Agent header
    v8_version: str               # V8 JavaScript engine version
    webkit_version: str            # WebKit version
    web_socket_debugger_url: str   # Browser WebSocket URL
```

---

## Exception Hierarchy

```
BrowserError (base)
├── ConnectionError   — Chrome not running or debug port unavailable
├── TabError          — Tab not found or operation failed
├── TimeoutError      — Operation timed out
└── CDPError          — Chrome DevTools Protocol error
```

All exceptions inherit from `BrowserError`. Users should catch `BrowserError` for broad coverage or specific subtypes for targeted handling.

```python
from browser_hybrid import Browser
from browser_hybrid.errors import BrowserError, ConnectionError, TimeoutError

try:
    browser.navigate("https://example.com")
    browser.wait_for(text="Welcome", timeout=5.0)
except ConnectionError:
    print("Chrome not running")
except TimeoutError:
    print("Page took too long to load")
except BrowserError as e:
    print(f"Browser error: {e}")
```

---

## CLI Reference

**Usage:** `browser-hybrid <command> [args]`

| Command | Args | Description |
|---|---|---|
| `list` | — | List all open tabs |
| `new` | `<url>` | Open new tab |
| `snapshot` | — | Print accessibility tree |
| `screenshot` | `[path]` | Save screenshot (default: `/tmp/screenshot.png`) |
| `html` | — | Print page HTML |
| `eval` | `<js>` | Execute JavaScript |
| `click` | `<text>` | Click element by text |
| `navigate` | `<url>` | Navigate current tab |
| `close` | `[id...]` | Close tabs (default: all non-chrome:// tabs) |
| `version` | — | Show Chrome version info |

**Examples:**

```bash
browser-hybrid list
browser-hybrid new https://gmail.com
browser-hybrid snapshot
browser-hybrid screenshot /tmp/page.png
browser-hybrid click "Sign in"
browser-hybrid eval "document.title"
browser-hybrid navigate https://github.com
browser-hybrid close
```

---

## Current Implementation Status (v0.2.6)

### Completed ✅

| Category | Feature | Version |
|----------|---------|---------|
| Tab Management | list_tabs, new_tab, activate_tab, close_tab | v0.1.0 |
| Tab Management | current_tab, get_version | v0.1.0 |
| Navigation | navigate, goto | v0.1.0 |
| Navigation | wait_for (selector/text/url) | v0.1.0 |
| Navigation | wait_for_selector_visible/hiden | v0.2.0 |
| Navigation | wait_for_load_state | v0.2.0 |
| Navigation | wait_for_navigation, wait_for_url | v0.2.0 |
| Navigation | wait_for_function | v0.2.0 |
| Accessibility | full AX tree via CDP | v0.1.0 |
| Accessibility | AXNode traversal (find, find_by_role, find_by_name) | v0.1.0 |
| Accessibility | AXNode output (to_tree_str, to_dict) | v0.1.0 |
| Click | click by ref | v0.1.0 |
| Click | click_by_text | v0.1.0 |
| Keyboard | type_text, type_by_name | v0.1.0 |
| Keyboard | type_slowly | v0.2.2 |
| Keyboard | press_key | v0.2.2 |
| Mouse | hover | v0.2.2 |
| Forms | fill_form | v0.1.0 |
| Content | screenshot | v0.1.0 |
| Content | get_html | v0.1.0 |
| Content | evaluate (JavaScript) | v0.1.0 |
| Cookies | get_cookies, set_cookies, delete_cookies | v0.2.0 |
| Network | on_request, on_response | v0.2.0 |
| Network | on_request_failed | v0.2.0 |
| Network | clear_interceptors | v0.2.0 |
| Connection | is_connected | v0.2.3 |
| Connection | wait_for_chrome | v0.2.3 |
| Connection | close | v0.2.3 |
| Connection | Auto-reconnection | v0.2.3 |
| CLI | All commands | v0.1.0 |
| Exceptions | BrowserError hierarchy | v0.1.0 |
| Types | Full type hints | v0.1.0 |
| Tests | Unit tests (mocked) | v0.1.0 |
| Tests | Integration markers | v0.1.0 |
| CI/CD | GitHub Actions | v0.2.0 |
| Package | PyPI publishing | v0.2.0 |

### Not Yet Implemented (v0.3.0+)

- [ ] Improved form detection (label/for, ARIA)
- [ ] PDF generation
- [ ] Multi-tab coordination
- [ ] Recording/playback
- [ ] File upload handling
- [ ] Dialog handling (alert, confirm, prompt)
- [ ] Frame switching (iframes)

---

## Platform Support

Tested on:
- macOS (Chrome, remote debugging)
- Linux (Chrome/Chromium, remote debugging)
- Windows (Chrome, remote debugging)

The CDP protocol is cross-platform; any Chrome/Chromium-based browser with `--remote-debugging-port` works.

---

## Security Considerations

1. **Localhost only** — CDP debugging on 9222 is localhost-only by default. Do not expose to network.
2. **Arbitrary JS execution** — `evaluate()` runs whatever script you pass. Treat accordingly.
3. **No sandbox** — BH uses your real Chrome session. Actions have real consequences (real emails sent, real data modified).
4. **No authentication** — Anyone with localhost CDP access can automate your browser.

---

## File Structure

```
browser-hybrid/
├── src/browser_hybrid/
│   ├── __init__.py       # Package init, exports Browser, Tab, AXNode, BrowserInfo
│   ├── __main__.py       # python -m browser_hybrid entry point
│   ├── browser.py        # Main Browser class + exceptions
│   ├── cli.py            # CLI implementation
│   └── models.py         # Tab, AXNode, BrowserInfo dataclasses
├── tests/
│   ├── test_browser.py   # Unit tests + integration markers
│   └── conftest.py       # Test fixtures
├── docs/                 # MkDocs documentation
│   ├── index.md          # Home page
│   ├── installation.md   # Setup guide
│   ├── quickstart.md     # Quick start
│   ├── api/              # API reference
│   ├── guides/           # How-to guides
│   └── internals/        # Internal docs (this file)
├── pyproject.toml        # Package config (hatchling)
├── README.md             # User-facing overview
├── CHANGELOG.md          # Version history
└── ROADMAP.md            # Future plans
```

---

## Design Decisions

### Why not use Playwright/Selenium DOM APIs?

BH deliberately uses the **accessibility tree** (via `Accessibility.getFullAXTree` CDP) rather than raw DOM or CSS selectors. This gives:
- Semantic element targeting (by role, name, label) instead of fragile selectors
- Ref-based targeting compatible with agent-browser
- Stable across page restructuring (DOM changes but AX tree semantics persist)
- Built-in filtering to interactive elements

### Why WebSockets for commands?

Tab operations (navigation, DOM, accessibility) require bidirectional communication. HTTP is sufficient for tab CRUD, but CDP commands (Page.navigate, Accessibility.getFullAXTree, Runtime.evaluate) require WebSocket. Each CDP command opens a WebSocket, does one operation, closes.

### Why stdlib + websockets only?

Minimal dependency surface. The `urllib` stdlib handles HTTP CDP endpoints. `websockets` handles the WebSocket CDP commands. No heavy browser automation framework needed.

### Why async internal but sync external?

The CDP WebSocket protocol is async. We use `asyncio.run()` internally to bridge sync→async. The public API is synchronous for simplicity. This may change to fully async in a future version if there's demand.

---

*Last updated: 2026-03-24*