# Vela Browser — Automation API Reference

_Version 1.5.238 · generated 2026-08-28 from the app's route table · 225 HTTP endpoints + WebSocket events._

Download this file: <https://velabrowser.com/api-reference.md> · Interactive docs: <https://velabrowser.com/api-docs.html> · In-app: `vela://api-documentation`


Vela is a macOS/iOS WebKit browser with anti-detect profiles. Every browser capability is exposed through a local REST + WebSocket API so scripts and AI agents (Codex, Claude Code, custom tools) can drive it: tabs, windows, profiles, fingerprints, proxies, cookies, page automation (click/type/fill/wait/extract), native OS input, file uploads, downloads, dialogs, and real-time events.

## Contents

1. [Quick start](#quick-start)
2. [Authentication](#authentication)
3. [Launching & operating modes](#launching--operating-modes)
4. [Conventions](#conventions)
5. [Errors & diagnostics](#errors--diagnostics)
6. [Timing semantics](#timing-semantics)
7. [Dialogs, permissions & downloads](#dialogs-permissions--downloads)
8. [File uploads (native picker)](#file-uploads-native-picker)
9. [Endpoint reference](#endpoint-reference) — every section below
10. [WebSocket events](#websocket-events)
11. [Hidden & headless mode](#hidden--headless-mode-from-the-web-docs)
12. [Data models](#data-models)
13. [Security notes](#security-notes)

## Quick start

```bash
# Enable the API: Settings ▸ Automation ▸ Enable Automation API (default port 1306), copy the key.
KEY=YOUR_KEY; B=http://127.0.0.1:1306

curl -s -H "X-API-Key: $KEY" $B/api/status
# → {"status":"ok","version":"1.5.238","uptime":12.3,"windowCount":1,"tabCount":3}

# Open a tab, wait for it, read the title
TAB=$(curl -s -X POST -H "X-API-Key: $KEY" -H "Content-Type: application/json" \
  -d '{"url":"https://example.com"}' $B/api/tabs | python3 -c 'import sys,json;print(json.load(sys.stdin)["id"])')
curl -s -X POST -H "X-API-Key: $KEY" -H "Content-Type: application/json" \
  -d '{"state":"load","timeout":15000}' $B/api/tabs/$TAB/wait-for-load-state
curl -s -X POST -H "X-API-Key: $KEY" -H "Content-Type: application/json" \
  -d '{"script":"document.title"}' $B/api/tabs/$TAB/execute

# Type into a field, click, screenshot
curl -s -X POST -H "X-API-Key: $KEY" -H "Content-Type: application/json" \
  -d '{"selector":"input[name=q]","text":"vela browser"}' $B/api/tabs/$TAB/type
curl -s -X POST -H "X-API-Key: $KEY" -H "Content-Type: application/json" \
  -d '{"selector":"button[type=submit]"}' $B/api/tabs/$TAB/click
curl -s -H "X-API-Key: $KEY" $B/api/tabs/$TAB/screenshot   # → {"data":"<base64 png>","format":"png"}
```

Every JSON body is sent with `Content-Type: application/json`. Ids (`:id`) are UUID strings returned by the API.

## Authentication

Every request except `GET /api/docs` needs the API key, either as the `X-API-Key` header or the `?apiKey=` query parameter. The key is generated the first time the server starts and shown in Settings ▸ Automation. Missing/wrong key → `401`. The server only accepts connections from this machine when bound to `127.0.0.1` (default).

## Launching & operating modes

All three modes expose the same API; they differ in how web views are hosted.

| Mode | How to start | What you get |
|---|---|---|
| **Headed** | Normal launch with the API enabled in Settings | Visible windows; API tabs are appended to the window's tab strip; native input can use real CGEvents (needs Accessibility permission) |
| **Hidden** | `open -n -a Vela --args --hidden` or `/Applications/Vela.app/Contents/MacOS/Vela --hidden` | Full headed browser (sessions, cookies, rendering) with every window kept transparent/offscreen; no dock icon after the first window |
| **Headless** | `/Applications/Vela.app/Contents/MacOS/Vela --headless [--port N] [--bind 127.0.0.1]` | No GUI. The process prints `API: http://127.0.0.1:PORT` and `Key: …` on stdout. Call `POST /api/windows` first — tabs need a window |

Flags: `--headless`, `--hidden`, `--port <n>` (overrides the saved port; default 1306 headed, 1307 headless), `--bind <host>` (`0.0.0.0` exposes the API to the network — see Security). Stop a background instance with `pkill -f "Vela.*--headless"`.

## Conventions

- **Tabs live in windows.** `POST /api/tabs` without `windowId` uses the first registered window; in headless mode create one with `POST /api/windows` (optionally `{ "profileId": … , "private": true }`).
- **Window convenience endpoints.** Every tab operation also exists as `/api/windows/:id/<operation>` and targets that window's active tab, so you don't need a tab id.
- **Clicks that open tabs.** `click`, `dblclick`, `mouse/click` and `native/click` honour `target="_blank"`, named targets and `window.open()`; when a click creates a tab the response includes `openedTabId`, `openedTabIds`, `openedTabs`, and WebSocket clients get `tab.created`.
- **Suspended tabs.** The memory saver releases background tabs' web views (`isSuspended: true` in `GET /api/tabs`). Page-level endpoints wake such a tab automatically (view restored, page reloaded, waited for); `POST /api/tabs/:id/wake` does it explicitly. Only if restoring fails do they answer `409 TAB_SUSPENDED`. API-created tabs are marked keep-alive so their page state (form input, JS heap) is not discarded.
- **Cookies.** `GET/POST/DELETE /api/tabs/:id/cookies` operate on the tab's own profile store, also while the tab is suspended; a suspended *private* tab answers `409` (its cookies exist only in memory).
- **JavaScript results.** `execute` returns any JSON-serializable value under `result`; Dates become ISO-8601 strings, `NaN`/`Infinity` become `null`, binary becomes base64 (large blobs are summarized), unpaired surrogates are repaired.
- **Validation.** Numeric inputs are range-checked (ports 0–65535, `vela_ram_limit_percent` 1–100, timeouts ≤ 300 000 ms); unknown profile/folder ids → `404`; `spoofTimezone` must be a valid zone id and `spoofLanguage` a comma-separated tag list.

## Errors & diagnostics

Error responses are JSON with a stable `code`:

| Status | `code` | Meaning |
|---|---|---|
| 400 | `BAD_REQUEST` | Missing/invalid parameter (bad JSON, `null` settings value, port out of range, non-object `eventInit`, …) |
| 401 | — | Missing or invalid API key |
| 403 | `FORBIDDEN_HOST` / `FORBIDDEN_ORIGIN` | The `Host` or `Origin` header is not local (`127.0.0.1`, `localhost`, `[::1]`): web pages cannot drive the API via DNS rebinding or cross-site `fetch()` even with the key |
| 404 | `NOT_FOUND` | Unknown tab / window / profile / folder id — **only** for ids that do not exist |
| 408 | — | Native file chooser did not open within `timeout` (`/upload`) |
| 409 | `TAB_SUSPENDED` | The tab exists but its web view could not be restored; call `/wake` or `/activate`, then retry |
| 409 | `CONFLICT` | The tab was closed (or its web view replaced) while the request was in flight; a download is not in the required state; Tor not connected |
| 413 / 431 | — | Request body over 10 MB / header block over 64 KB (connection closed) |
| 500 | `JS_EXCEPTION` | The page script threw. Includes `exception.message`, `exception.line`, `exception.column`, `exception.sourceURL` (never the script) and `partialSideEffectsPossible: true` — JavaScript execution is not transactional |
| 500 | `JS_RESULT_UNSUPPORTED` | The script returned a value WebKit cannot serialize, or a navigation destroyed the document while the script was pending; return plain objects/arrays/strings/numbers |
| 500 | `WEB_PROCESS_TERMINATED` / `WEBVIEW_INVALIDATED` | The tab's content process died or the view was recreated — reload or use a fresh tab |
| 500 | `INTERNAL_ERROR` | Anything else (`domain`, `errorCode` included) |
| 503 | — | API key not configured |

Every response carries an `X-Request-Id` header. Requests are logged to the macOS unified log — subsystem `com.vela.browser.automation`, category `api` — with method, path, status, duration and request id, never bodies, keys, cookies or scripts:

```bash
log stream --predicate 'subsystem == "com.vela.browser.automation"'
```

Malformed HTTP is answered with `400` and the connection is closed. Response bodies are always valid JSON.

## Timing semantics

- `wait`, `wait-for-timeout`, `wait-for-function`, `wait-for-load-state`, `wait-for-navigation`, `wait-for-url`, `type` and `mouse/move|random|path` respond only once the page-side promise has settled (the wait elapsed or matched, typing finished, the movement completed).
- `timeout`/`duration` values are clamped to 300 000 ms; a request may legitimately take up to 300 s and is never reaped while in flight (a 330 s hard deadline closes the connection if a handler never returns).
- `wait-for-navigation` counts a navigation already in flight when it is called, so `navigate` followed by `wait-for-navigation` works.
- A request awaiting page JavaScript on a tab that gets closed fails within ~0.3 s with `409 CONFLICT` instead of hanging.

## Dialogs, permissions & downloads

JavaScript `alert()`, `confirm()` and `prompt()` never block the API:

1. A per-tab policy set with `PUT /api/tabs/:id/dialog/config` (`accept` / `dismiss`, optional `promptText`) resolves the dialog immediately.
2. A tab in a window a person can see shows a non-blocking sheet; the dialog is also registered so `POST /dialog/accept` / `/dialog/dismiss` can resolve it.
3. A tab nobody can see (headless, hidden, API-owned offscreen tab) is auto-dismissed (alert → OK, confirm → Cancel, prompt → null) unless the policy is explicitly `none`, which queues it for the API (`GET /dialog` shows it; 120 s timeout).

`dialog.opened` / `dialog.closed` events are broadcast either way. Camera/microphone and "open in another app" prompts follow the same rule (sheet when visible, denied/cancelled when unattended). A page-triggered download that needs a destination is saved to Downloads instead of opening a Save panel when nobody can see the window.

## File uploads (native picker)

Sites like Facebook catalog / Meta Commerce open a hidden `<input type=file>` and the native macOS picker; assigning files from JavaScript (`DataTransfer`) stalls their editors. Vela completes WKWebView's real file chooser with filesystem paths:

1. `POST /api/tabs/:id/upload` with `{ "paths": ["/absolute/video.mp4"] }` — stages the file and returns immediately (`staged: true`). Do not send videos as base64.
2. Click the Replace / upload control (via any click endpoint). Vela intercepts the picker and hands the page a real `FileList`.

One-shot: include `"selector"` (the control to click) and optional `"timeout"` in the same `/upload` body. For long sessions use `PUT /api/tabs/:id/file-chooser/config` with `{ "action": "intercept" }`, then `GET /file-chooser` and `POST /file-chooser/accept`. Small files may also be sent inline as `{ "files": [{ "name": "a.txt", "data": "<base64>", "type": "text/plain" }] }` (≤ 1.5 MB, JS assignment). In hidden/headless modes pickers are always intercepted.

## Endpoint reference


### Status

| Method | Path | Description |
|---|---|---|
| `GET` | `/api/status` | Health check with version, uptime, window/tab counts |
| `GET` | `/api/docs` | This documentation page (no API key required) |

#### `GET /api/status`

_Health check with version, uptime, counts_

Returns server status, browser version, uptime, and current window/tab counts.

**Response:**

```json
{
  "status": "running",
  "version": "1.0.0",
  "uptime": 3600.5,
  "windowCount": 2,
  "tabCount": 8,
  "automationPort": 1306,
  "bindAddress": "127.0.0.1"
}
```

**curl:**

```bash
curl -H "X-API-Key: YOUR_KEY" http://127.0.0.1:1306/api/status
```

#### `GET /api/docs`

_Built-in HTML documentation (no auth)_

Returns a built-in HTML documentation page. No API key required.

**curl:**

```bash
curl http://127.0.0.1:1306/api/docs
```

**Notes:** Returns HTML

### Tabs

| Method | Path | Description |
|---|---|---|
| `GET` | `/api/tabs` | List all tabs (?windowId= optional filter) |
| `GET` | `/api/tabs/:id` | Get tab details |
| `POST` | `/api/tabs` | Create tab — { "url"?, "windowId"?, "profileId"?, "private"? }. API-created tabs are appended at the end of the window's tab strip (predictable ordering for scripts). Tabs a user opens from a link go next to the current tab instead. |
| `DELETE` | `/api/tabs/:id` | Close tab |
| `POST` | `/api/tabs/:id/navigate` | Navigate — { "url": "..." } |
| `POST` | `/api/tabs/:id/reload` | Reload page |
| `POST` | `/api/tabs/:id/back` | Go back |
| `POST` | `/api/tabs/:id/forward` | Go forward |
| `POST` | `/api/tabs/:id/stop` | Stop loading |
| `POST` | `/api/tabs/:id/activate` | Switch to tab (also restores a suspended tab's web view) |
| `POST` | `/api/tabs/:id/wake` | Restore the web view of a suspended tab (isSuspended: true) and reload its URL — { } → { "woken", "hasWebView", "isSuspended", "url" }. Page-level endpoints wake suspended tabs automatically; call this explicitly to pre-warm. |
| `POST` | `/api/tabs/:id/duplicate` | Duplicate tab |
| `POST` | `/api/tabs/:id/pin` | Toggle pin |
| `POST` | `/api/tabs/:id/mute` | Toggle mute |
| `GET` | `/api/tabs/:id/source` | Get HTML source |

#### `GET /api/tabs`

_List all tabs_

Returns all open tabs across all windows. Optionally filter by window ID. Includes tabs opened by `target="_blank"`, named targets, and `window.open()` during API clicks.

**Query Parameters:**

| Param | Description |
|---|---|
| windowId (optional) | Filter tabs to a specific window |

**Response:**

```json
[{
  "id": "550e8400-...",
  "windowId": "...",
  "title": "Example",
  "url": "https://example.com",
  "isLoading": false,
  "canGoBack": true,
  "isPinned": false,
  "isPrivate": false,
  "isSuspended": false
}]
```

**curl:**

```bash
curl -H "X-API-Key: YOUR_KEY" http://127.0.0.1:1306/api/tabs
```

#### `GET /api/tabs/:id`

Returns full details for a specific tab by ID.

**curl:**

```bash
curl -H "X-API-Key: YOUR_KEY" http://127.0.0.1:1306/api/tabs/TAB_ID
```

#### `POST /api/tabs`

_Create a new tab_

Opens a new tab. Optionally specify URL, target window, profile, or private mode.

API-created tabs are appended at the end of the window's tab strip, so programmatic ordering stays predictable. (Tabs a user opens from a link/`target="_blank"` are instead placed next to the current tab.)

**Body:**

```json
{
  "url": "https://example.com",
  "windowId": "...",
  "profileId": "...",
  "private": false
}
```

**curl:**

```bash
curl -X POST -H "X-API-Key: YOUR_KEY" -H "Content-Type: application/json" \
  -d '{"url":"https://example.com"}' \
  http://127.0.0.1:1306/api/tabs
```

**Notes:** All fields optional. API-created tabs are appended at the end of the tab strip (predictable ordering); tabs a user opens from a link go next to the current tab.

#### `DELETE /api/tabs/:id`

_Close a tab_

Closes the specified tab.

**curl:**

```bash
curl -X DELETE -H "X-API-Key: YOUR_KEY" http://127.0.0.1:1306/api/tabs/TAB_ID
```

#### `POST /api/tabs/:id/navigate`

_Navigate to URL_

Navigates the tab to a new URL.

**Body:**

```json
{ "url": "https://example.com" }
```

**curl:**

```bash
curl -X POST -H "X-API-Key: YOUR_KEY" -H "Content-Type: application/json" \
  -d '{"url":"https://example.com"}' \
  http://127.0.0.1:1306/api/tabs/TAB_ID/navigate
```

#### `POST /api/tabs/:id/reload`

Reloads the current page in the tab.

**curl:**

```bash
curl -X POST -H "X-API-Key: YOUR_KEY" http://127.0.0.1:1306/api/tabs/TAB_ID/reload
```

#### `POST /api/tabs/:id/back`

Navigate back in history.

#### `POST /api/tabs/:id/forward`

Navigate forward in history.

#### `POST /api/tabs/:id/stop`

Stops the current page from loading.

#### `POST /api/tabs/:id/activate`

_Switch to tab_

Makes this tab the active/selected tab in its window. Also restores the web view of a suspended tab.

#### `POST /api/tabs/:id/wake`

_Restore a suspended tab_

Recreates the web view of a tab whose view was released to save memory (`isSuspended: true` in `GET /api/tabs`) and reloads its URL, waiting briefly for the page. Page-level endpoints do this automatically; use `/wake` to pre-warm a tab. If the view cannot be restored, page-level endpoints answer `409 TAB_SUSPENDED` instead of an ambiguous 404.

**Response:**

```json
{ "tabId": "…", "woken": true, "hasWebView": true, "isSuspended": false, "url": "https://…" }
```

**Response example:** `{ "woken": true, "hasWebView": true, "isSuspended": false, "url": "https://…" }`

**Notes:** Page-level endpoints wake suspended tabs automatically; if the view cannot be restored they answer 409 TAB_SUSPENDED (404 is reserved for unknown ids). Errors are JSON with a stable `code`; every response carries an X-Request-Id header.

#### `POST /api/tabs/:id/duplicate`

Creates a copy of the tab with the same URL.

#### `POST /api/tabs/:id/pin`

Toggles the pinned state of the tab.

**Response example:** `{ "isPinned": true }`

#### `POST /api/tabs/:id/mute`

Toggles the muted state of the tab.

**Response example:** `{ "isMuted": true }`

#### `GET /api/tabs/:id/source`

_Get page HTML source_

Returns the full HTML source of the current page.

**Response:**

```json
{ "source": "<!DOCTYPE html>..." }
```

**Response example:** `{ "html": "<html>...</html>" }`

### Page Automation

| Method | Path | Description |
|---|---|---|
| `POST` | `/api/tabs/:id/execute` | Run JS — { "script": "..." } |
| `POST` | `/api/tabs/:id/click` | Click — { "selector": "..." }. target=_blank, named targets, and window.open may return openedTabId/openedTabs |
| `POST` | `/api/tabs/:id/fill` | Fill input — { "selector": "...", "value": "..." } |
| `POST` | `/api/tabs/:id/select` | Select dropdown option(s) — { "selector", one of: "value" \| "values": ["a","b"] (multi) \| "label" \| "index" } |
| `POST` | `/api/tabs/:id/check` | Checkbox — { "selector": "...", "checked": true } |
| `POST` | `/api/tabs/:id/type` | Type text — { "selector": "...", "text": "...", "delay"?: 50 } |
| `POST` | `/api/tabs/:id/wait` | Wait for element — { "selector": "...", "timeout"?: 10000 } |
| `POST` | `/api/tabs/:id/extract` | Extract — { "selector": "...", "attribute"?: "href", "multiple"?: true } |
| `POST` | `/api/tabs/:id/scroll` | Scroll — { "y": 500 } or { "selector": "..." } |
| `POST` | `/api/tabs/:id/login` | Auto-fill credentials — { "domain": "..." } |
| `GET` | `/api/tabs/:id/screenshot` | Screenshot (base64 PNG) |
| `GET` | `/api/tabs/:id/pdf` | Export PDF (base64) |
| `GET` | `/api/tabs/:id/cookies` | Get cookies |
| `POST` | `/api/tabs/:id/cookies` | Set cookies |
| `DELETE` | `/api/tabs/:id/cookies` | Clear cookies |
| `GET` | `/api/tabs/:id/text` | Get visible text |
| `POST` | `/api/tabs/:id/upload` | Upload files via native picker — { "paths": ["/abs/video.mp4"] } or { "files": [{ "name", "data", "type" }] } + optional "selector", "timeout"?. Completes WKWebView's file chooser with real filesystem URLs (required for large MP4s / Facebook catalog). |

#### `POST /api/tabs/:id/execute`

_Execute JavaScript_

Runs arbitrary JavaScript in the page context and returns the result.

**Body:**

```json
{ "script": "document.title" }
```

**Response:**

```json
{ "result": "Example Domain" }
```

**curl:**

```bash
curl -X POST -H "X-API-Key: YOUR_KEY" -H "Content-Type: application/json" \
  -d '{"script":"document.title"}' \
  http://127.0.0.1:1306/api/tabs/TAB_ID/execute
```

#### `POST /api/tabs/:id/click`

_Click element_

Clicks the first element matching the CSS selector. If the click opens a `target="_blank"`, named-target, or `window.open()` tab, the response includes `openedTabId`, `openedTabIds`, and `openedTabs`.

**Body:**

```json
{ "selector": "button.submit" }
```

**Response when a new tab opens:**

```json
{
  "success": true,
  "openedTabId": "550e8400-...",
  "openedTabIds": ["550e8400-..."],
  "openedTabs": [{ "id": "550e8400-...", "url": "https://example.com" }]
}
```

**Request example:** `{ "selector": "#submit-btn" }`

**Response example:** `{ "success": true, "openedTabId": "uuid", "openedTabs": [...] }`

**Notes:** If the click triggers target=_blank, a named target, or window.open, the new tab is tracked by the API and returned in openedTabId/openedTabs.

#### `POST /api/tabs/:id/fill`

_Fill input field_

Sets the value of an input field using native value setter (compatible with React, Angular, Vue).

**Body:**

```json
{
  "selector": "input[name='email']",
  "value": "user@example.com"
}
```

**curl:**

```bash
curl -X POST -H "X-API-Key: YOUR_KEY" -H "Content-Type: application/json" \
  -d '{"selector":"input[name=email]","value":"user@example.com"}' \
  http://127.0.0.1:1306/api/tabs/TAB_ID/fill
```

**Request example:** `{ "selector": "#email", "value": "user@example.com" }`

#### `POST /api/tabs/:id/select`

_Select dropdown option_

Selects option(s) in a <select> element. Provide one of: `value` (string), `values` (array, for multi-select), `label` (visible text), or `index`.

**Body:**

```json
{ "selector": "select#country", "value": "US" }
```

**Request example:** `{ "selector": "#country", "value": "US" }`

**Notes:** Provide one of: value (string), values (["a","b"] for multi-select), label (visible text), or index.

#### `POST /api/tabs/:id/check`

_Set checkbox state_

Sets the checked state of a checkbox or radio input.

**Body:**

```json
{ "selector": "#agree", "checked": true }
```

#### `POST /api/tabs/:id/type`

_Type text character by character_

Types text into the focused element with optional per-keystroke delay (simulates real typing).

**Body:**

```json
{
  "selector": "input#search",
  "text": "Hello World",
  "delay": 50
}
```

| Param | Description |
|---|---|
| selector | CSS selector for the target element |
| text | Text to type |
| delay (optional) | Delay in ms between keystrokes (default: 0) |

**Request example:** `{ "selector": "#search", "text": "hello", "delay": 50 }`

**Notes:** delay is milliseconds between characters

#### `POST /api/tabs/:id/wait`

_Wait for element_

Waits until an element matching the selector appears in the DOM (uses MutationObserver).

**Body:**

```json
{ "selector": ".loaded", "timeout": 10000 }
```

| Param | Description |
|---|---|
| selector | CSS selector to wait for |
| timeout (optional) | Timeout in ms (default: 10000) |

**Request example:** `{ "selector": ".result", "timeout": 10000 }`

**Response example:** `{ "found": true }`

**Notes:** timeout default: 10000ms

#### `POST /api/tabs/:id/extract`

_Extract data from elements_

Extracts text content or attribute values from one or more elements.

**Body:**

```json
{
  "selector": "a.link",
  "attribute": "href",
  "multiple": true
}
```

**Response:**

```json
{ "result": ["/page1", "/page2"] }
```

**Request example:** `{ "selector": "a.link", "attribute": "href", "multiple": true }`

**Response example:** `{ "result": ["url1", "url2"] }`

#### `POST /api/tabs/:id/scroll`

_Scroll page_

Scrolls to absolute coordinates or to a specific element.

**Body (coordinates):**

```json
{ "x": 0, "y": 500 }
```

**Body (selector):**

```json
{ "selector": "#footer" }
```

**Request example:** `{ "y": 500 } or { "selector": "#footer" }`

#### `POST /api/tabs/:id/login`

_Auto-fill credentials_

Looks up saved credentials for the domain and auto-fills the login form.

**Body:**

```json
{ "domain": "github.com" }
```

**Response example:** `{ "filled": true, "username": "user" }`

**Notes:** Uses saved passwords

#### `GET /api/tabs/:id/screenshot`

_Take screenshot (base64 PNG)_

Captures a screenshot of the visible page and returns it as base64-encoded PNG.

**Response:**

```json
{ "data": "iVBORw0KGgo..." }
```

**Response example:** `{ "data": "base64...", "format": "png" }`

**Notes:** macOS only

#### `GET /api/tabs/:id/pdf`

_Export page as PDF_

Exports the current page as a PDF document returned as base64.

**Response:**

```json
{ "data": "JVBERi0..." }
```

**Response example:** `{ "data": "base64...", "format": "pdf" }`

#### `GET /api/tabs/:id/cookies`

Returns all cookies for the tab's data store.

#### `POST /api/tabs/:id/cookies`

Sets one or more cookies in the tab's data store.

**Body:**

```json
{
  "cookies": [{
    "name": "session",
    "value": "abc123",
    "domain": "example.com",
    "path": "/"
  }]
}
```

**Request example:** `{ "cookies": [{ "name": "session", "value": "abc", "domain": ".example.com" }] }`

#### `DELETE /api/tabs/:id/cookies`

Removes all cookies from the tab's data store.

#### `GET /api/tabs/:id/text`

Returns the visible text content of the page (innerText of body).

**Response:**

```json
{ "text": "Page content here..." }
```

**Response example:** `{ "text": "..." }`

#### `POST /api/tabs/:id/upload`

_Upload files via native picker_

Hands files to the page through WKWebView's **native file picker**. This is required for Facebook catalog / Meta Commerce video replacement and any site that `.click()`s a hidden file input. Prefer filesystem `paths` for MP4s — do not send videos as base64 (the HTTP body cap is 10 MB and in-page decode freezes the tab).

**Body:**

```json
{
  "paths": ["/Users/me/video.mp4"],
  "selector": "button",
  "timeout": 20000
}
```

| Param | Description |
|---|---|
| paths | Absolute filesystem paths. Best for videos. |
| path | Single path shorthand |
| files[].path | Filesystem path as an item in `files` |
| files[].name / data / type | Base64 fallback for small images only |
| selector (optional) | Clicks the Replace/upload control first (searches same-origin iframes and shadow roots) |
| timeout (optional) | Milliseconds to wait when `selector` is set (default 15000). Ignored for stage-only uploads, which return immediately. |

**Catalog video replacement:** `POST /upload` with `paths` (stages the MP4 and returns immediately), then click Replace video. Or pass the button `selector` in the same request. Vela completes the native picker with a real FileList so Meta's editor can reach a savable state.

**Response (stage-only):**

```json
{ "success": true, "staged": true, "method": "staged", "filesSet": 1 }
```

**Response (picker completed):**

```json
{ "success": true, "method": "fileChooser", "filesSet": 1 }
```

**curl:**

```bash
curl -X POST -H "X-API-Key: YOUR_KEY" -H "Content-Type: application/json" \
  -d '{"paths":["/Users/me/video.mp4"]}' \
  http://127.0.0.1:1306/api/tabs/TAB_ID/upload
```

**Notes:** Prefer filesystem 'paths' for MP4s. Completes WKWebView's native picker so React/Meta receive a real FileList. Base64 'files' still works for small images. Optional selector clicks the replace/upload control first.

### Mouse Simulation

| Method | Path | Description |
|---|---|---|
| `POST` | `/api/tabs/:id/mouse/move` | Move cursor — { "x", "y" } or { "selector" } + "duration"?, "steps"?, "easing"? |
| `POST` | `/api/tabs/:id/mouse/click` | Click — { "x", "y" } or { "selector" } + "button"?, "doubleClick"?. New target tabs are returned as openedTabId/openedTabs |
| `POST` | `/api/tabs/:id/mouse/down` | Mouse button down — { "x", "y" } or { "selector" } |
| `POST` | `/api/tabs/:id/mouse/up` | Mouse button up — { "x", "y" } or { "selector" } |
| `POST` | `/api/tabs/:id/mouse/random` | Random human-like movement — { "duration": 3000, "area"?, "speed"? } |
| `POST` | `/api/tabs/:id/mouse/path` | Move along waypoints — { "points": [...], "duration": 1000, "easing"? } |

#### `POST /api/tabs/:id/mouse/move`

_Move mouse cursor with easing_

Smoothly moves the simulated mouse cursor to a target position or CSS selector. Dispatches pointermove and mousemove events along the path. Supports multiple easing modes including human-like movement with random jitter.

**Request Body:**

```json
{
  "x": 500,
  "y": 300,
  "duration": 200,
  "steps": 10,
  "easing": "ease"
}
```

| Param | Type | Description |
|---|---|---|
| x | number | Target X coordinate (or use selector) |
| y | number | Target Y coordinate (or use selector) |
| selector | string | CSS selector — moves to element center (alternative to x/y) |
| duration | number | Movement duration in ms (default: 100) |
| steps | number | Number of intermediate steps (default: 10) |
| easing | string | "linear", "ease", or "human" (adds ±3px jitter) |

**cURL Example:**

```bash
curl -X POST -H "X-API-Key: YOUR_KEY" -H "Content-Type: application/json" \
  -d '{"selector":"#submit-btn","duration":300,"easing":"human"}' \
  http://127.0.0.1:1306/api/tabs/TAB_ID/mouse/move
```

**Response:**

```json
{ "success": true, "x": 500, "y": 300, "element": "button" }
```

**Parameters:** `{ "x", "y" } or { "selector" } + "duration"?, "steps"?, "easing"?`

**Request example:** `{ "x": 500, "y": 300, "easing": "human" }`

**Notes:** easing: ease | linear | human

#### `POST /api/tabs/:id/mouse/click`

_Full click sequence at position or selector_

Dispatches a complete click event sequence at the target: pointerdown → mousedown → pointerup → mouseup → click. Optionally fires dblclick for double-click. If the click opens a target-new or `window.open()` tab, the response includes `openedTabId` and `openedTabs`.

**Request Body:**

```json
{
  "selector": "#login-btn",
  "button": "left",
  "doubleClick": false
}
```

| Param | Type | Description |
|---|---|---|
| x / y | number | Target coordinates (or use selector) |
| selector | string | CSS selector — clicks element center |
| button | string | "left" (default), "right", or "middle" |
| doubleClick | boolean | Also fire dblclick event (default: false) |

**Response:**

```json
{ "success": true, "x": 250, "y": 180, "element": "button" }
```

**Parameters:** `{ "selector" } or { "x", "y" } + "button"?, "doubleClick"?`

**Request example:** `{ "selector": "#btn" }`

**Response example:** `{ "success": true, "openedTabId": "uuid", "openedTabs": [...] }`

**Notes:** button: left | right | middle. New target tabs are returned as openedTabId/openedTabs.

#### `POST /api/tabs/:id/mouse/down`

_Mouse button down_

Dispatches pointerdown and mousedown events at the target position. Use with /mouse/up for drag operations.

**Request Body:**

```json
{ "x": 200, "y": 150, "button": "left" }
```

**Response:**

```json
{ "success": true, "x": 200, "y": 150, "element": "div" }
```

**Parameters:** `{ "x", "y" } or { "selector" }`

#### `POST /api/tabs/:id/mouse/up`

_Mouse button up_

Dispatches pointerup and mouseup events at the target position. Use after /mouse/down to complete drag operations.

**Request Body:**

```json
{ "x": 400, "y": 300, "button": "left" }
```

**Response:**

```json
{ "success": true, "x": 400, "y": 300, "element": "div" }
```

**Parameters:** `{ "x", "y" } or { "selector" }`

#### `POST /api/tabs/:id/mouse/random`

_Random human-like mouse movement_

Generates random human-like mouse movements for a specified duration. Moves between random targets within a bounding area with ease-in-out curves and micro-jitter. Useful for anti-detection and making automated sessions appear natural.

**Request Body:**

```json
{
  "duration": 3000,
  "area": { "x": 100, "y": 100, "width": 800, "height": 600 },
  "speed": "normal"
}
```

| Param | Type | Description |
|---|---|---|
| duration | number | How long to move in milliseconds (required) |
| area | object | Bounding box { x, y, width, height } — defaults to viewport |
| speed | string | "slow" (80ms), "normal" (40ms), or "fast" (20ms) step intervals |

**cURL Example:**

```bash
curl -X POST -H "X-API-Key: YOUR_KEY" -H "Content-Type: application/json" \
  -d '{"duration":5000,"speed":"slow"}' \
  http://127.0.0.1:1306/api/tabs/TAB_ID/mouse/random
```

**Response:**

```json
{ "success": true, "moves": 87, "finalX": 432.5, "finalY": 287.3 }
```

**Parameters:** `{ "duration" } + "area"?, "speed"?`

**Request example:** `{ "duration": 3000 }`

#### `POST /api/tabs/:id/mouse/path`

_Move along array of waypoints_

Moves the mouse cursor along a sequence of waypoints with proportional timing based on segment distances. Each segment uses the specified easing function.

**Request Body:**

```json
{
  "points": [
    { "x": 100, "y": 100 },
    { "x": 300, "y": 200 },
    { "x": 500, "y": 150 }
  ],
  "duration": 1000,
  "easing": "human"
}
```

| Param | Type | Description |
|---|---|---|
| points | array | Array of { x, y } waypoints (minimum 2, required) |
| duration | number | Total duration in ms (default: 500) |
| easing | string | "linear", "ease", or "human" |

**Response:**

```json
{ "success": true, "x": 500, "y": 150, "points": 3 }
```

**Parameters:** `{ "points": [{"x","y"}, ...], "duration"? }`

**Request example:** `{ "points": [{ "x": 100, "y": 100 }, { "x": 400, "y": 250 }], "duration": 1000 }`

### Windows

| Method | Path | Description |
|---|---|---|
| `GET` | `/api/windows` | List all windows |
| `GET` | `/api/windows/:id` | Get window details with tabs |
| `POST` | `/api/windows` | Open window — { "profileId"?, "private"? } → { "opened", "windowId", "tabId" }. Supports concurrent creation of many windows at once. In GUI mode opens real visible windows; in hidden/headless mode creates instantly without UI. |
| `POST` | `/api/windows/:id/activate` | Activate window — brings window to front and activates the app (headed mode). In hidden/headless mode this is a no-op. |
| `DELETE` | `/api/windows/:id` | Close window — closes via direct window reference and deregisters from API |

#### `GET /api/windows`

Returns all open browser windows.

**Response:**

```json
[{
  "id": "...",
  "tabCount": 5,
  "isPrivate": false,
  "profileId": "...",
  "currentTabId": "..."
}]
```

#### `GET /api/windows/:id`

_Get window details_

Returns details for a specific window.

#### `POST /api/windows`

_Open new window_

Opens a new browser window, optionally with a specific profile or in private mode. Returns the new window ID and active tab ID.

**Body:**

```json
{ "profileId": "...", "private": false }
```

**Response:**

```json
{ "opened": true, "windowId": "UUID", "tabId": "UUID" }
```

**Request example:** `{ "profileId": "uuid", "private": false }`

**Notes:** In GUI mode, may take a moment while the window is created. In hidden/headless mode, returns immediately. macOS only.

#### `POST /api/windows/:id/activate`

_Activate window (bring to front)_

Brings the window to the foreground and activates the Vela app. In headed mode, makes the window the key window and calls `NSApplication.activate(ignoringOtherApps: true)`. In hidden/headless mode this is a no-op.

**Response:**

```json
{ "activated": true }
```

**Notes:** In headed mode, makes the window key and brings it to front, activating the app. In hidden/headless mode this is a no-op. Useful for ensuring windows are visible during automation.

#### `DELETE /api/windows/:id`

_Close window_

Closes the specified browser window and all its tabs. Uses a direct NSWindow reference for reliable closing, and always deregisters the window from the API even if the physical close fails.

**Response example:** `{ "closed": true }`

**Notes:** Closes via direct NSWindow reference and deregisters from API. Always removes from window list even if the physical window close fails. macOS only.

### Window → Active Tab

| Method | Path | Description |
|---|---|---|
| `POST` | `/api/windows/:id/navigate` | Navigate active tab — { "url": "..." } |
| `POST` | `/api/windows/:id/reload` | Reload active tab |
| `POST` | `/api/windows/:id/back` | Go back on active tab |
| `POST` | `/api/windows/:id/forward` | Go forward on active tab |
| `POST` | `/api/windows/:id/stop` | Stop loading on active tab |
| `POST` | `/api/windows/:id/duplicate` | Duplicate active tab |
| `POST` | `/api/windows/:id/pin` | Toggle pin on active tab |
| `POST` | `/api/windows/:id/mute` | Toggle mute on active tab |
| `GET` | `/api/windows/:id/source` | Get HTML source of active tab |
| `POST` | `/api/windows/:id/execute` | Run JS on active tab |
| `POST` | `/api/windows/:id/click` | Click element on active tab; new target tabs are returned as openedTabId/openedTabs |
| `POST` | `/api/windows/:id/fill` | Fill input on active tab |
| `POST` | `/api/windows/:id/select` | Select dropdown on active tab |
| `POST` | `/api/windows/:id/check` | Checkbox on active tab |
| `POST` | `/api/windows/:id/type` | Type text on active tab |
| `POST` | `/api/windows/:id/wait` | Wait for element on active tab |
| `POST` | `/api/windows/:id/extract` | Extract from active tab |
| `POST` | `/api/windows/:id/scroll` | Scroll on active tab |
| `POST` | `/api/windows/:id/login` | Auto-fill credentials on active tab |
| `GET` | `/api/windows/:id/screenshot` | Screenshot active tab |
| `GET` | `/api/windows/:id/pdf` | PDF from active tab |
| `GET` | `/api/windows/:id/cookies` | Get cookies from active tab |
| `POST` | `/api/windows/:id/cookies` | Set cookies on active tab |
| `DELETE` | `/api/windows/:id/cookies` | Clear cookies on active tab |
| `GET` | `/api/windows/:id/text` | Get visible text from active tab |
| `POST` | `/api/windows/:id/upload` | Upload files on active tab (same body as /tabs/:id/upload; prefers filesystem paths) |
| `POST` | `/api/windows/:id/mouse/move` | Mouse move on active tab |
| `POST` | `/api/windows/:id/mouse/click` | Mouse click on active tab; new target tabs are returned as openedTabId/openedTabs |
| `POST` | `/api/windows/:id/mouse/down` | Mouse down on active tab |
| `POST` | `/api/windows/:id/mouse/up` | Mouse up on active tab |
| `POST` | `/api/windows/:id/mouse/random` | Random mouse movement on active tab |
| `POST` | `/api/windows/:id/mouse/path` | Mouse path on active tab |
| `POST` | `/api/windows/:id/hover` | Hover element on active tab |
| `POST` | `/api/windows/:id/focus` | Focus element on active tab |
| `POST` | `/api/windows/:id/dblclick` | Double-click on active tab; new target tabs are returned as openedTabId/openedTabs |
| `POST` | `/api/windows/:id/press` | Press key on active tab |
| `POST` | `/api/windows/:id/uncheck` | Uncheck checkbox on active tab |

#### `POST /api/windows/:id/navigate`

_Navigate active tab_

Navigate the window's active tab to a URL. Body: `{ "url": "https://..." }`

**Parameters:** `{ "url": "..." }`

**Notes:** Window-level mirror: targets the window's active tab (same body and response as the /api/tabs/:id counterpart; wakes a suspended tab automatically).

#### `POST /api/windows/:id/reload`

Reload the current page on the window's active tab.

**Notes:** Window-level mirror: targets the window's active tab (same body and response as the /api/tabs/:id counterpart; wakes a suspended tab automatically).

#### `POST /api/windows/:id/back`

Navigate back in the active tab's history.

**Notes:** Window-level mirror: targets the window's active tab (same body and response as the /api/tabs/:id counterpart; wakes a suspended tab automatically).

#### `POST /api/windows/:id/forward`

Navigate forward in the active tab's history.

**Notes:** Window-level mirror: targets the window's active tab (same body and response as the /api/tabs/:id counterpart; wakes a suspended tab automatically).

#### `POST /api/windows/:id/stop`

_Stop loading_

Stop loading the page on the active tab.

**Notes:** Window-level mirror: targets the window's active tab (same body and response as the /api/tabs/:id counterpart; wakes a suspended tab automatically).

#### `POST /api/windows/:id/duplicate`

Open a copy of the window's active tab.

**Notes:** Window-level mirror: targets the window's active tab (same body and response as the /api/tabs/:id counterpart; wakes a suspended tab automatically).

#### `POST /api/windows/:id/pin`

Toggle the pinned state of the window's active tab.

**Notes:** Window-level mirror: targets the window's active tab (same body and response as the /api/tabs/:id counterpart; wakes a suspended tab automatically).

#### `POST /api/windows/:id/mute`

Toggle audio mute on the window's active tab.

**Notes:** Window-level mirror: targets the window's active tab (same body and response as the /api/tabs/:id counterpart; wakes a suspended tab automatically).

#### `GET /api/windows/:id/source`

Return the full HTML source of the window's active tab.

**Notes:** Window-level mirror: targets the window's active tab (same body and response as the /api/tabs/:id counterpart; wakes a suspended tab automatically).

#### `POST /api/windows/:id/execute`

_Run JavaScript on active tab_

Execute JavaScript on the active tab. Body: `{ "script": "document.title" }`

**Notes:** Window-level mirror: targets the window's active tab (same body and response as the /api/tabs/:id counterpart; wakes a suspended tab automatically).

#### `POST /api/windows/:id/click`

_Click element on active tab_

Click an element. Body: `{ "selector": "button.submit" }`. If it opens a target-new tab, the response includes `openedTabId` and `openedTabs`.

**Notes:** Window-level mirror: targets the window's active tab (same body and response as the /api/tabs/:id counterpart; wakes a suspended tab automatically).

#### `POST /api/windows/:id/fill`

Fill an input field. Body: `{ "selector": "input[name='email']", "value": "user@example.com" }`

**Notes:** Window-level mirror: targets the window's active tab (same body and response as the /api/tabs/:id counterpart; wakes a suspended tab automatically).

#### `POST /api/windows/:id/select`

Select a dropdown option. Body: `{ "selector": "select#country", "value": "US" }`

**Notes:** Window-level mirror: targets the window's active tab (same body and response as the /api/tabs/:id counterpart; wakes a suspended tab automatically).

#### `POST /api/windows/:id/check`

_Toggle checkbox on active tab_

Set checkbox state. Body: `{ "selector": "#agree", "checked": true }`

**Notes:** Window-level mirror: targets the window's active tab (same body and response as the /api/tabs/:id counterpart; wakes a suspended tab automatically).

#### `POST /api/windows/:id/type`

Type text character by character. Body: `{ "selector": "#search", "text": "hello", "delay": 50 }`

**Notes:** Window-level mirror: targets the window's active tab (same body and response as the /api/tabs/:id counterpart; wakes a suspended tab automatically).

#### `POST /api/windows/:id/wait`

Wait for an element to appear. Body: `{ "selector": ".loaded", "timeout": 10000 }`

**Notes:** Window-level mirror: targets the window's active tab (same body and response as the /api/tabs/:id counterpart; wakes a suspended tab automatically).

#### `POST /api/windows/:id/extract`

_Extract content from active tab_

Extract text or attributes. Body: `{ "selector": "h1", "attribute": "textContent", "multiple": false }`

**Notes:** Window-level mirror: targets the window's active tab (same body and response as the /api/tabs/:id counterpart; wakes a suspended tab automatically).

#### `POST /api/windows/:id/scroll`

Scroll by offset or to element. Body: `{ "y": 500 }` or `{ "selector": "#footer" }`

**Notes:** Window-level mirror: targets the window's active tab (same body and response as the /api/tabs/:id counterpart; wakes a suspended tab automatically).

#### `POST /api/windows/:id/login`

Fill saved credentials. Body: `{ "domain": "example.com" }`

**Notes:** Window-level mirror: targets the window's active tab (same body and response as the /api/tabs/:id counterpart; wakes a suspended tab automatically).

#### `GET /api/windows/:id/screenshot`

Capture a screenshot of the active tab as base64 PNG.

**Notes:** Window-level mirror: targets the window's active tab (same body and response as the /api/tabs/:id counterpart; wakes a suspended tab automatically).

#### `GET /api/windows/:id/pdf`

_Export PDF from active tab_

Export the active tab as a base64-encoded PDF.

**Notes:** Window-level mirror: targets the window's active tab (same body and response as the /api/tabs/:id counterpart; wakes a suspended tab automatically).

#### `GET /api/windows/:id/cookies`

Get cookies for the active tab's current domain.

**Notes:** Window-level mirror: targets the window's active tab (same body and response as the /api/tabs/:id counterpart; wakes a suspended tab automatically).

#### `POST /api/windows/:id/cookies`

Set cookies. Body: `{ "cookies": [{ "name": "...", "value": "...", "domain": "..." }] }`

**Notes:** Window-level mirror: targets the window's active tab (same body and response as the /api/tabs/:id counterpart; wakes a suspended tab automatically).

#### `DELETE /api/windows/:id/cookies`

Clear all cookies for the active tab's current domain.

**Notes:** Window-level mirror: targets the window's active tab (same body and response as the /api/tabs/:id counterpart; wakes a suspended tab automatically).

#### `GET /api/windows/:id/text`

Get all visible text content from the active tab.

**Notes:** Window-level mirror: targets the window's active tab (same body and response as the /api/tabs/:id counterpart; wakes a suspended tab automatically).

#### `POST /api/windows/:id/upload`

_Upload files on active tab_

Same as `/api/tabs/:id/upload` on the window's active tab. Prefer `{ "paths": ["/abs/video.mp4"] }` for catalog videos.

**Notes:** Window-level mirror: targets the window's active tab (same body and response as the /api/tabs/:id counterpart; wakes a suspended tab automatically).

#### `POST /api/windows/:id/mouse/move`

_Move cursor on active tab_

Move mouse cursor with easing on the active tab. Body: `{ "x": 500, "y": 300 }` or `{ "selector": "#btn" }` + optional `"duration"`, `"steps"`, `"easing"` (ease|linear|human).

**Notes:** Window-level mirror: targets the window's active tab (same body and response as the /api/tabs/:id counterpart; wakes a suspended tab automatically).

#### `POST /api/windows/:id/mouse/click`

_Click on active tab_

Full click sequence on active tab. Body: `{ "selector": "#btn" }` + optional `"button"` (left|right|middle), `"doubleClick"`. New target tabs are returned as `openedTabId`/`openedTabs`.

**Notes:** Window-level mirror: targets the window's active tab (same body and response as the /api/tabs/:id counterpart; wakes a suspended tab automatically).

#### `POST /api/windows/:id/mouse/down`

Dispatches pointerdown + mousedown on the active tab.

**Notes:** Window-level mirror: targets the window's active tab (same body and response as the /api/tabs/:id counterpart; wakes a suspended tab automatically).

#### `POST /api/windows/:id/mouse/up`

Dispatches pointerup + mouseup on the active tab.

**Notes:** Window-level mirror: targets the window's active tab (same body and response as the /api/tabs/:id counterpart; wakes a suspended tab automatically).

#### `POST /api/windows/:id/mouse/random`

Human-like random mouse movement on the active tab. Body: `{ "duration": 3000 }` + optional `"area"`, `"speed"`.

**Notes:** Window-level mirror: targets the window's active tab (same body and response as the /api/tabs/:id counterpart; wakes a suspended tab automatically).

#### `POST /api/windows/:id/mouse/path`

Move along waypoints on the active tab. Body: `{ "points": [{"x":100,"y":100}, ...], "duration": 1000 }`

**Notes:** Window-level mirror: targets the window's active tab (same body and response as the /api/tabs/:id counterpart; wakes a suspended tab automatically).

#### `POST /api/windows/:id/hover`

Hover over an element. Body: `{ "selector": ".menu" }`

**Notes:** Window-level mirror: targets the window's active tab (same body and response as the /api/tabs/:id counterpart; wakes a suspended tab automatically).

#### `POST /api/windows/:id/focus`

Focus an element. Body: `{ "selector": "input#email" }`

**Notes:** Window-level mirror: targets the window's active tab (same body and response as the /api/tabs/:id counterpart; wakes a suspended tab automatically).

#### `POST /api/windows/:id/dblclick`

_Double-click on active tab_

Double-click an element. Body: `{ "selector": "#item" }`. New target tabs are returned as `openedTabId`/`openedTabs`.

**Notes:** Window-level mirror: targets the window's active tab (same body and response as the /api/tabs/:id counterpart; wakes a suspended tab automatically).

#### `POST /api/windows/:id/press`

Dispatch a key press. Body: `{ "key": "Enter" }`

**Notes:** Window-level mirror: targets the window's active tab (same body and response as the /api/tabs/:id counterpart; wakes a suspended tab automatically).

#### `POST /api/windows/:id/uncheck`

Uncheck a checkbox. Body: `{ "selector": "#agree" }`

**Notes:** Window-level mirror: targets the window's active tab (same body and response as the /api/tabs/:id counterpart; wakes a suspended tab automatically).

### Profiles

| Method | Path | Description |
|---|---|---|
| `GET` | `/api/profiles` | List all profiles with full settings |
| `GET` | `/api/profiles/:id` | Get profile details including proxy, DNS, cookies, permissions |
| `POST` | `/api/profiles` | Create profile — { "name", "icon"?, "color"?, ... } |
| `PUT` | `/api/profiles/:id` | Update any profile field (partial update) |
| `DELETE` | `/api/profiles/:id` | Delete profile (cannot delete default) |
| `POST` | `/api/profiles/:id/activate` | Switch window to profile — { "windowId"? } |
| `POST` | `/api/profiles/:id/duplicate` | Duplicate profile with all settings |
| `POST` | `/api/profiles/:id/regenerate-fingerprint` | Generate new fingerprint seed |

#### `GET /api/profiles`

_List all profiles_

Returns all browser profiles with their configuration.

**Response:**

```json
[{
  "id": "...",
  "name": "Work",
  "icon": "briefcase",
  "color": "blue",
  "fingerprintSeed": 12345,
  "fingerprintEnabled": true,
  "spoofLanguage": "en-US,en",
  "spoofTimezone": "America/New_York",
  "userAgentId": "chrome_mac",
  "proxyConfig": null
}]
```

#### `GET /api/profiles/:id`

_Get profile details_

Returns full details for a specific profile.

**Notes:** Includes fingerprint, UA, proxy, privacy settings

#### `POST /api/profiles`

_Create profile with all settings_

Creates a new browser profile with isolated cookies, storage, and fingerprint. Accepts ALL profile settings on creation.

**Body (all fields optional except name):**

```json
{
  "name": "Shopping",
  "icon": "cart",
  "color": "#10B981",
  "userAgentId": "chrome_mac",
  "fingerprintEnabled": true,
  "fingerprintSeed": 123456,
  "proxyConfig": {
    "type": "SOCKS5",
    "host": "proxy.example.com",
    "port": 1080,
    "username": "user",
    "password": "pass",
    "isEnabled": true
  },
  "dnsProvider": "cloudflare",
  "contentBlockerEnabled": true,
  "blockTrackers": true,
  "blockAds": true,
  "blockPopups": true,
  "httpsFirstEnabled": true,
  "torEnabled": false,
  "autofillEnabled": true,
  "savePasswordEnabled": true,
  "spoofLanguage": "en-US,en",
  "spoofTimezone": "America/New_York",
  "sessionCookies": [{
    "name": "session",
    "value": "abc123",
    "domain": ".example.com",
    "path": "/",
    "isSecure": true
  }]
}
```

| Param | Description |
|---|---|
| name | Profile name (required) |
| icon (optional) | SF Symbol name (person.circle, briefcase, house, cart, etc.) |
| color (optional) | Hex color (e.g. #4A90D9) |
| proxyConfig (optional) | Per-profile proxy: type (HTTP/SOCKS5), host, port, username, password, isEnabled |
| dnsProvider (optional) | DNS: system, cloudflare, google, quad9, adguard, opendns, cleanbrowsing, nextdns |
| sessionCookies (optional) | Array of cookies to store with the profile |
| privacy flags (optional) | contentBlockerEnabled, blockTrackers, blockAds, blockPopups, httpsFirstEnabled, torEnabled |
| autofillEnabled (optional) | Enable/disable password autofill for this profile (null = use global) |
| savePasswordEnabled (optional) | Enable/disable save-password prompts for this profile (null = use global) |
| spoofLanguage (optional) | Override navigator.languages / Accept-Language, e.g. "en-US,en" or "fr-FR,fr" (null = use browser default) |
| spoofTimezone (optional) | Override reported timezone via Intl/Date APIs, e.g. "America/New_York" or "Europe/Paris" (null = use system timezone) |

**curl:**

```bash
curl -X POST -H "X-API-Key: YOUR_KEY" -H "Content-Type: application/json" \
  -d '{"name":"Shopping","icon":"cart","color":"#10B981","dnsProvider":"cloudflare","proxyConfig":{"type":"SOCKS5","host":"proxy.example.com","port":1080,"isEnabled":true}}' \
  http://127.0.0.1:1306/api/profiles
```

**Request example:** `{ "name": "Work", "icon": "briefcase", "color": "#10B981", "userAgentId": "chrome_win" }`

#### `PUT /api/profiles/:id`

_Update profile_

Partially update any profile field (name, icon, color, UA, fingerprint, proxy, DNS, privacy settings).

**Body (partial update):**

```json
{ "name": "New Name", "fingerprintEnabled": true }
```

**Request example:** `{ "name": "Updated", "fingerprintEnabled": true, "proxyConfig": { "type": "HTTP", "host": "proxy.com", "port": 8080 } }`

#### `DELETE /api/profiles/:id`

_Delete profile_

Deletes a profile. Cannot delete the default profile.

**Notes:** Cannot delete default profile

#### `POST /api/profiles/:id/activate`

_Switch window to profile_

Switches a window to use this profile.

**Body:**

```json
{ "windowId": "..." }
```

**Request example:** `{ "windowId": "uuid" }`

**Notes:** windowId optional

#### `POST /api/profiles/:id/duplicate`

_Duplicate profile_

Creates a copy of the profile with a new fingerprint seed.

#### `POST /api/profiles/:id/regenerate-fingerprint`

_New fingerprint seed_

Generates a new random fingerprint seed for the profile.

**Response example:** `{ "regenerated": true, "newSeed": 456789 }`

### User Agents

| Method | Path | Description |
|---|---|---|
| `GET` | `/api/user-agents` | List 48+ UA presets with categories |
| `GET` | `/api/user-agents/current` | Current global UA |
| `PUT` | `/api/user-agents/current` | Set global UA — { "id": "..." } |
| `PUT` | `/api/profiles/:id/user-agent` | Set profile UA — { "id": "..." } |

#### `GET /api/user-agents`

_List 48+ UA presets_

Returns all available user agent presets organized by category (Desktop, Mobile, Bots, etc.).

**Response:**

```json
[{
  "id": "chrome_mac",
  "name": "Chrome on macOS",
  "value": "Mozilla/5.0 ...",
  "category": "Desktop",
  "isSafeForLogin": true
}]
```

#### `GET /api/user-agents/current`

_Get current global UA_

Returns the currently active global user agent.

#### `PUT /api/user-agents/current`

_Set global UA_

**Body:**

```json
{ "id": "iphone16promax" }
```

#### `PUT /api/profiles/:id/user-agent`

_Set profile UA_

**Body:**

```json
{ "id": "chrome_windows" }
```

**Request example:** `{ "id": "chrome_win" }`

### Fingerprints

| Method | Path | Description |
|---|---|---|
| `GET` | `/api/fingerprint` | Global fingerprint protection status |
| `PUT` | `/api/fingerprint` | Toggle — { "enabled": true } |
| `GET` | `/api/profiles/:id/fingerprint` | Profile fingerprint config |
| `PUT` | `/api/profiles/:id/fingerprint` | Update — { "enabled"?, "seed"? } |
| `POST` | `/api/profiles/:id/fingerprint/regenerate` | New random seed |

#### `GET /api/fingerprint`

_Global fingerprint status_

**Response:**

```json
{ "enabled": true, "seed": 42 }
```

#### `PUT /api/fingerprint`

_Toggle fingerprint protection_

**Body:**

```json
{ "enabled": true }
```

#### `GET /api/profiles/:id/fingerprint`

Returns the fingerprint configuration for a specific profile.

**Response:**

```json
{
  "enabled": true,
  "seed": 12345,
  "spoofLanguage": "en-US,en",
  "spoofTimezone": "America/New_York"
}
```

**Response example:** `{ "enabled": true, "seed": 123456, "spoofLanguage": "en-US,en", "spoofTimezone": "America/New_York" }`

#### `PUT /api/profiles/:id/fingerprint`

_Update profile fingerprint_

**Body:**

```json
{
  "enabled": true,
  "seed": 99999,
  "spoofLanguage": "en-US,en",
  "spoofTimezone": "America/New_York"
}
```

| Param | Description |
|---|---|
| enabled (optional) | Enable/disable fingerprint protection |
| seed (optional) | Integer seed for deterministic fingerprint values |
| spoofLanguage (optional) | Override navigator.languages, e.g. "en-US,en" (null to clear) |
| spoofTimezone (optional) | Override timezone via Intl/Date APIs, e.g. "America/New_York" (null to clear) |

**Request example:** `{ "enabled": true, "seed": 654321, "spoofLanguage": "en-US,en", "spoofTimezone": "America/New_York" }`

**Notes:** spoofLanguage: e.g. "en-US,en". spoofTimezone: IANA name e.g. "America/New_York". Pass null to clear.

#### `POST /api/profiles/:id/fingerprint/regenerate`

Generates a new random fingerprint seed for the profile.

**Response example:** `{ "newSeed": 789012 }`

### Real Browser

| Method | Path | Description |
|---|---|---|
| `GET` | `/api/real-browser` | Global Real Browser status (vanilla WKWebView, no fingerprint script) |
| `PUT` | `/api/real-browser` | Toggle — { "enabled": true }. Disables Fingerprint Protection while on. |
| `GET` | `/api/profiles/:id/real-browser` | Per-profile override (null = follow global) |
| `PUT` | `/api/profiles/:id/real-browser` | Set profile override — { "enabled": true\|false\|null } |

#### `GET /api/real-browser`

_Global Real Browser status_

**Response:**

```json
{
  "enabled": true,
  "fingerprintProtection": false
}
```

#### `PUT /api/real-browser`

_Toggle Real Browser_

**Body:**

```json
{ "enabled": true }
```

Setting `enabled: true` automatically disables Fingerprint Protection (and vice versa via `PUT /api/fingerprint`).

**Notes:** Default for new installs is enabled. Existing installs keep prior setting.

#### `GET /api/profiles/:id/real-browser`

_Per-profile override_

Returns the per-profile override (`null` means "follow global") and the effective resolved value used for tabs in this profile.

**Response:**

```json
{ "override": true, "effective": true }
```

**Notes:** override is null when the profile follows the global setting.

#### `PUT /api/profiles/:id/real-browser`

_Set profile override_

**Body:**

```json
{ "enabled": true }
```

| Param | Description |
|---|---|
| enabled | `true` = force Real Browser on for this profile, `false` = force off, `null` = clear override and follow global |

**Notes:** Pass enabled: null to clear the override and follow global.

### Settings

| Method | Path | Description |
|---|---|---|
| `GET` | `/api/settings` | All vela_* settings |
| `GET` | `/api/settings/:key` | Get setting |
| `PUT` | `/api/settings/:key` | Set — { "value": ... } |

#### `GET /api/settings`

_List all settings_

Returns all `vela_*` UserDefaults settings as key-value pairs.

**Response:**

```json
[
  { "key": "vela_automation_port", "value": 1306 },
  { "key": "vela_dark_mode", "value": true }
]
```

#### `GET /api/settings/:key`

_Get specific setting_

Returns the value for a single setting key.

**Response example:** `{ "key": "vela_homepage", "value": "..." }`

**Notes:** Key can omit vela_ prefix

#### `PUT /api/settings/:key`

_Update setting_

**Body:**

```json
{ "value": true }
```

**curl:**

```bash
curl -X PUT -H "X-API-Key: YOUR_KEY" -H "Content-Type: application/json" \
  -d '{"value": true}' \
  http://127.0.0.1:1306/api/settings/vela_dark_mode
```

**Request example:** `{ "value": "https://example.com" }`

### Bookmarks

| Method | Path | Description |
|---|---|---|
| `GET` | `/api/bookmarks` | List (?folderId= optional filter) |
| `POST` | `/api/bookmarks` | Create — { "title", "url", "folderId"? } |
| `PUT` | `/api/bookmarks/:id` | Update bookmark |
| `DELETE` | `/api/bookmarks/:id` | Delete bookmark |
| `GET` | `/api/bookmarks/folders` | List folders |
| `POST` | `/api/bookmarks/folders` | Create folder — { "name", "parentId"? } |
| `DELETE` | `/api/bookmarks/folders/:id` | Delete folder |

#### `GET /api/bookmarks`

_List bookmarks_

Returns all bookmarks. Optionally filter by folder.

| Param | Description |
|---|---|
| folderId (optional) | Filter to a specific folder |

#### `POST /api/bookmarks`

_Create bookmark_

**Body:**

```json
{
  "title": "Example",
  "url": "https://example.com",
  "folderId": "..."
}
```

**Request example:** `{ "title": "Example", "url": "https://example.com", "folderId": "uuid" }`

**Notes:** folderId optional

#### `PUT /api/bookmarks/:id`

Update a bookmark's title, URL, or folder.

**Request example:** `{ "title": "New Title", "url": "https://new.com" }`

#### `DELETE /api/bookmarks/:id`

Removes a bookmark.

#### `GET /api/bookmarks/folders`

Returns all bookmark folders.

#### `POST /api/bookmarks/folders`

_Create folder_

**Body:**

```json
{ "name": "Dev Tools", "parentId": "..." }
```

**Request example:** `{ "name": "Work", "parentId": "uuid" }`

#### `DELETE /api/bookmarks/folders/:id`

Removes a bookmark folder.

### History

| Method | Path | Description |
|---|---|---|
| `GET` | `/api/history` | List (?limit=&query= optional) |
| `GET` | `/api/history/top` | Top visited sites |
| `DELETE` | `/api/history` | Clear all history |
| `DELETE` | `/api/history/:id` | Delete single entry |

#### `GET /api/history`

_List history entries_

| Param | Description |
|---|---|
| limit (optional) | Max results (default: 100) |
| query (optional) | Search text filter |

#### `GET /api/history/top`

Returns the most frequently visited sites.

#### `DELETE /api/history`

Permanently deletes all browsing history.

#### `DELETE /api/history/:id`

Removes a single history entry by ID.

### Downloads

| Method | Path | Description |
|---|---|---|
| `GET` | `/api/downloads` | List downloads |
| `GET` | `/api/downloads/:id` | Get download details |
| `POST` | `/api/downloads` | Start — { "url", "fileName"? } |
| `POST` | `/api/downloads/:id/pause` | Pause download |
| `POST` | `/api/downloads/:id/resume` | Resume download |
| `DELETE` | `/api/downloads/:id` | Cancel/remove download |

#### `GET /api/downloads`

**Response:**

```json
[{
  "id": "...",
  "fileName": "file.zip",
  "url": "https://...",
  "state": "completed",
  "progress": 1.0,
  "totalBytes": 1048576,
  "downloadedBytes": 1048576
}]
```

#### `GET /api/downloads/:id`

Returns details for a single download including progress, state, and byte counts.

**Response:**

```json
{
  "id": "...",
  "fileName": "file.zip",
  "url": "https://...",
  "state": "downloading",
  "progress": 0.45,
  "totalBytes": 1048576,
  "downloadedBytes": 471859
}
```

#### `POST /api/downloads`

_Start download_

Starts downloading a file from the given URL.

**Body:**

```json
{ "url": "https://example.com/file.zip", "fileName": "custom.zip" }
```

| Param | Description |
|---|---|
| url | URL of the file to download |
| fileName (optional) | Custom file name (defaults to URL's last path component) |

**Request example:** `{ "url": "https://example.com/file.zip", "fileName": "file.zip" }`

**Notes:** fileName optional

#### `POST /api/downloads/:id/pause`

Pauses an in-progress download. The download can be resumed later with the resume endpoint.

**curl:**

```bash
curl -X POST -H "X-API-Key: YOUR_KEY" \
  http://127.0.0.1:1306/api/downloads/DOWNLOAD_ID/pause
```

**Response example:** `{ "paused": true }`

**Notes:** Download must be in 'downloading' state

#### `POST /api/downloads/:id/resume`

Resumes a paused download from where it left off.

**curl:**

```bash
curl -X POST -H "X-API-Key: YOUR_KEY" \
  http://127.0.0.1:1306/api/downloads/DOWNLOAD_ID/resume
```

**Response example:** `{ "resumed": true }`

**Notes:** Download must be in 'paused' state

#### `DELETE /api/downloads/:id`

Cancels an in-progress download or removes a completed one.

### Passwords

| Method | Path | Description |
|---|---|---|
| `GET` | `/api/passwords` | List (passwords hidden) |
| `GET` | `/api/passwords/:id` | Full entry (requires X-Password-Access: true) |
| `POST` | `/api/passwords/search` | Search — { "domain": "..." } |
| `POST` | `/api/passwords/generate` | Generate — { "length"?, "symbols"? } |

#### `GET /api/passwords`

_List credentials (passwords hidden)_

Lists all saved credentials. Passwords are not included in the response for security.

**Response:**

```json
[{
  "id": "...",
  "domain": "github.com",
  "username": "user@example.com",
  "title": "GitHub"
}]
```

#### `GET /api/passwords/:id`

_Get full credential (requires extra header)_

Returns the full credential including password. Requires the `X-Password-Access: true` header for additional security.

**curl:**

```bash
curl -H "X-API-Key: YOUR_KEY" -H "X-Password-Access: true" \
  http://127.0.0.1:1306/api/passwords/PASS_ID
```

**Notes:** Requires X-Password-Access: true header

#### `POST /api/passwords/search`

_Search by domain_

**Body:**

```json
{ "domain": "github.com" }
```

#### `POST /api/passwords/generate`

_Generate secure password_

**Body:**

```json
{ "length": 24, "symbols": true }
```

**Response:**

```json
{ "password": "xK9#mP2$vL7@nQ4..." }
```

**Response example:** `{ "password": "..." }`

### Content Blocker

| Method | Path | Description |
|---|---|---|
| `GET` | `/api/content-blocker` | Status (enabled, blocked count, bandwidth saved, extra lists, automaticCookieConsent) |
| `PUT` | `/api/content-blocker` | Update — { "enabled"?, "blockAds"?, "blockTrackers"?, "blockPopups"?, "extraBlockTrackers"?, "extraBlockAds"?, "automaticCookieConsent"? } |

#### `GET /api/content-blocker`

_Get content blocker status_

**Response:**

```json
{
  "enabled": true,
  "blockAds": true,
  "blockTrackers": true,
  "blockPopups": true,
  "extraBlockTrackers": false,
  "extraBlockAds": false,
  "automaticCookieConsent": true
}
```

#### `PUT /api/content-blocker`

_Update content blocker_

**Body:**

```json
{
  "enabled": true,
  "blockAds": true,
  "blockTrackers": true,
  "blockPopups": false,
  "extraBlockTrackers": true,
  "extraBlockAds": false,
  "automaticCookieConsent": true
}
```

**Note:** `extraBlockTrackers` enables 80+ additional tracker domains. `extraBlockAds` enables 60+ additional ad networks. Both are off by default. `automaticCookieConsent` handles cookie consent dialogs on supported sites.

**Request example:** `{ "enabled": true, "blockAds": false, "extraBlockTrackers": true, "automaticCookieConsent": true }`

**Notes:** extraBlockTrackers and extraBlockAds enable extended blocking lists (80+ tracker domains, 60+ ad networks). Off by default. automaticCookieConsent handles cookie consent dialogs on supported sites.

### Profile Cookies

| Method | Path | Description |
|---|---|---|
| `GET` | `/api/profiles/:id/cookies` | Get stored session cookies |
| `PUT` | `/api/profiles/:id/cookies` | Set session cookies — { "cookies": [...] } |
| `DELETE` | `/api/profiles/:id/cookies` | Clear all stored session cookies |
| `POST` | `/api/profiles/:id/cookies/inject` | Inject stored cookies into live data store |
| `GET` | `/api/profiles/:id/cookies/live` | Get ALL live cookies (?domain= optional) |

#### `GET /api/profiles/:id/cookies`

Returns all session cookies stored in the profile configuration.

**Response:**

```json
{
  "cookies": [{
    "id": "...",
    "name": "session",
    "value": "abc123",
    "domain": ".example.com",
    "path": "/",
    "isSecure": true,
    "isHttpOnly": false
  }],
  "count": 1
}
```

#### `PUT /api/profiles/:id/cookies`

_Set session cookies_

Replaces all stored session cookies for the profile.

**Body:**

```json
{
  "cookies": [{
    "name": "session",
    "value": "abc123",
    "domain": ".example.com",
    "path": "/",
    "isSecure": true,
    "isHttpOnly": false
  }]
}
```

**curl:**

```bash
curl -X PUT -H "X-API-Key: YOUR_KEY" -H "Content-Type: application/json" \
  -d '{"cookies":[{"name":"session","value":"abc123","domain":".example.com"}]}' \
  http://127.0.0.1:1306/api/profiles/PROFILE_ID/cookies
```

**Request example:** `{ "cookies": [{ "name": "session", "value": "abc", "domain": ".example.com" }] }`

#### `DELETE /api/profiles/:id/cookies`

_Clear session cookies_

Removes all stored session cookies from the profile.

#### `POST /api/profiles/:id/cookies/inject`

_Inject cookies into live data store_

Takes the profile's stored session cookies and injects them into the live WKWebsiteDataStore, making them immediately available to web pages.

**Response:**

```json
{ "injected": 5 }
```

**Notes:** Applies stored session cookies to the running WKWebsiteDataStore.

#### `GET /api/profiles/:id/cookies/live`

_Get ALL live cookies from data store_

Returns all cookies currently in the profile's live data store (not just stored ones). Optionally filter by domain.

| Param | Description |
|---|---|
| domain (optional) | Filter cookies to a specific domain |

**curl:**

```bash
curl -H "X-API-Key: YOUR_KEY" "http://127.0.0.1:1306/api/profiles/PROFILE_ID/cookies/live?domain=example.com"
```

**Parameters:** `Query: ?domain= (optional filter)`

### Profile Export / Import

| Method | Path | Description |
|---|---|---|
| `POST` | `/api/profiles/:id/export` | Export profile as JSON |
| `POST` | `/api/profiles/import` | Import profile from JSON body |
| `POST` | `/api/profiles/import-url` | Import from remote URL — { "url": "..." } |

#### `POST /api/profiles/:id/export`

Exports a complete profile including all settings, proxy config, DNS, privacy flags, session cookies, and ALL live cookies from the data store. The output can be fed directly into the import endpoint.

**Response (truncated):**

```json
{
  "id": "...",
  "name": "Work",
  "proxyConfig": { ... },
  "dnsProvider": "cloudflare",
  "sessionCookies": [...],
  "liveCookies": [...],
  "liveCookieCount": 42,
  "exportedAt": "2025-01-15T10:30:00Z"
}
```

**curl:**

```bash
curl -X POST -H "X-API-Key: YOUR_KEY" http://127.0.0.1:1306/api/profiles/PROFILE_ID/export
```

#### `POST /api/profiles/import`

_Import profile from JSON_

Creates a new profile from JSON data (same format as export). Live cookies are injected into the new profile's data store.

**Body:**

```
// Pass the full JSON from the export endpoint as the body
{ "name": "Work", "proxyConfig": {...}, "liveCookies": [...] }
```

#### `POST /api/profiles/import-url`

_Import profile from remote URL_

Downloads a profile ZIP from a remote URL (Dropbox, Google Drive, direct links) and imports it.

**Body:**

```json
{ "url": "https://dropbox.com/s/.../profile.zip" }
```

**Request example:** `{ "url": "https://example.com/profile.json" }`

### Profile Size & Data

| Method | Path | Description |
|---|---|---|
| `GET` | `/api/profiles/:id/size` | Get profile storage breakdown |
| `DELETE` | `/api/profiles/:id/data` | Clear ALL browsing data for profile |

#### `GET /api/profiles/:id/size`

_Get profile data store size_

Returns the number of data records, cookies, and data type breakdown for a profile's storage.

**Response:**

```json
{
  "profileId": "...",
  "totalRecords": 15,
  "cookieCount": 42,
  "dataTypes": {
    "WKWebsiteDataTypeCookies": 8,
    "WKWebsiteDataTypeLocalStorage": 5,
    "WKWebsiteDataTypeDiskCache": 2
  },
  "displayNames": ["example.com", "google.com"]
}
```

#### `DELETE /api/profiles/:id/data`

_Clear all profile browsing data_

Removes ALL browsing data for the profile (cookies, cache, local storage, indexed databases, etc.).

**curl:**

```bash
curl -X DELETE -H "X-API-Key: YOUR_KEY" http://127.0.0.1:1306/api/profiles/PROFILE_ID/data
```

**Notes:** Cookies, cache, localStorage, etc.

### Profile Permissions

| Method | Path | Description |
|---|---|---|
| `GET` | `/api/profiles/:id/permissions` | Get all privacy/permission settings (includes autofill &amp; save password) |
| `PUT` | `/api/profiles/:id/permissions` | Update — { "contentBlockerEnabled"?, "blockTrackers"?, "blockAds"?, "blockPopups"?, "extraBlockTrackers"?, "extraBlockAds"?, "httpsFirstEnabled"?, "torEnabled"?, "fingerprintEnabled"?, "autofillEnabled"?, "savePasswordEnabled"?, "resetToGlobal"? } |

#### `GET /api/profiles/:id/permissions`

_Get privacy settings_

Returns all privacy/permission settings for the profile. `null` values mean the profile inherits the global setting.

**Response:**

```json
{
  "contentBlockerEnabled": true,
  "blockTrackers": true,
  "blockAds": true,
  "blockPopups": null,
  "httpsFirstEnabled": true,
  "torEnabled": false,
  "fingerprintEnabled": true,
  "automaticCookieConsent": true
}
```

**Notes:** Includes autofill & save-password settings.

#### `PUT /api/profiles/:id/permissions`

_Update privacy settings_

Update privacy/permission settings for the profile. Pass `"resetToGlobal": true` to clear all overrides and use global settings.

**Body:**

```json
{
  "contentBlockerEnabled": true,
  "blockTrackers": true,
  "blockAds": false,
  "httpsFirstEnabled": true,
  "torEnabled": false,
  "fingerprintEnabled": true,
  "automaticCookieConsent": true
}
```

**Reset to global:**

```json
{ "resetToGlobal": true }
```

**Request example:** `{ "contentBlockerEnabled": true, "blockAds": true, "torEnabled": false, "fingerprintEnabled": true, "resetToGlobal": false }`

**Notes:** Fields: contentBlockerEnabled, blockTrackers, blockAds, blockPopups, extraBlockTrackers, extraBlockAds, httpsFirstEnabled, torEnabled, fingerprintEnabled, autofillEnabled, savePasswordEnabled, resetToGlobal — all optional.

### Profile Autofill

| Method | Path | Description |
|---|---|---|
| `GET` | `/api/profiles/:id/autofill` | Get per-profile autofill &amp; save-password settings (null = use global) |
| `PUT` | `/api/profiles/:id/autofill` | Update — { "autofillEnabled"?, "savePasswordEnabled"?, "resetToGlobal"? } |

#### `GET /api/profiles/:id/autofill`

_Get per-profile autofill settings_

Returns autofill and save-password settings for a specific profile. `null` values mean the profile uses the global setting.

**Response:**

```json
{
  "profileId": "...",
  "autofillEnabled": null,
  "savePasswordEnabled": null,
  "note": "null values mean the profile uses global settings"
}
```

**curl:**

```bash
curl -H "X-API-Key: YOUR_KEY" http://127.0.0.1:1306/api/profiles/PROFILE_ID/autofill
```

#### `PUT /api/profiles/:id/autofill`

_Update per-profile autofill settings_

Enable or disable autofill and save-password prompts for a specific profile. Pass `resetToGlobal: true` to clear overrides and inherit global settings.

**Body:**

```json
{
  "autofillEnabled": false,
  "savePasswordEnabled": false
}
```

| Param | Description |
|---|---|
| autofillEnabled (optional) | Enable/disable password autofill for this profile |
| savePasswordEnabled (optional) | Enable/disable save-password prompts for this profile |
| resetToGlobal (optional) | Set to true to clear overrides and use global settings |

**curl:**

```bash
curl -X PUT -H "X-API-Key: YOUR_KEY" -H "Content-Type: application/json" \
  -d '{"autofillEnabled":false,"savePasswordEnabled":false}' \
  http://127.0.0.1:1306/api/profiles/PROFILE_ID/autofill
```

**Request example:** `{ "autofillEnabled": false, "savePasswordEnabled": false }`

**Response example:** `{ "profileId": "uuid", "autofillEnabled": false, "savePasswordEnabled": false }`

**Notes:** Pass resetToGlobal: true to clear overrides and inherit global settings

### Global Proxy

| Method | Path | Description |
|---|---|---|
| `GET` | `/api/proxy` | Get global proxy config |
| `PUT` | `/api/proxy` | Set — { "enabled"?, "type"?, "host"?, "port"?, "username"?, "password"? } |

#### `GET /api/proxy`

Returns the global proxy configuration (applies to profiles without their own proxy).

**Response:**

```json
{
  "enabled": true,
  "type": "HTTP",
  "host": "proxy.example.com",
  "port": 8080,
  "username": "user",
  "password": "pass"
}
```

#### `PUT /api/proxy`

_Set global proxy_

Update the global proxy configuration. All fields are optional (partial update).

**Body:**

```json
{
  "enabled": true,
  "type": "SOCKS5",
  "host": "proxy.example.com",
  "port": 1080,
  "username": "user",
  "password": "pass"
}
```

**curl:**

```bash
curl -X PUT -H "X-API-Key: YOUR_KEY" -H "Content-Type: application/json" \
  -d '{"enabled":true,"type":"SOCKS5","host":"proxy.example.com","port":1080}' \
  http://127.0.0.1:1306/api/proxy
```

**Request example:** `{ "enabled": true, "type": "HTTP", "host": "proxy.com", "port": 8080, "username": "user", "password": "pass" }`

**Notes:** All fields optional.

### Global DNS

| Method | Path | Description |
|---|---|---|
| `GET` | `/api/dns` | Get current DNS provider + list available |
| `PUT` | `/api/dns` | Set provider — { "provider": "cloudflare" } |
| `POST` | `/api/dns/probe` | Measure latency — { "provider": "..." } |
| `POST` | `/api/dns/probe-all` | Measure all providers (sorted by speed) |

#### `GET /api/dns`

_Get DNS provider + list all_

Returns the current DNS provider and lists all available providers with details.

**Response:**

```json
{
  "current": "cloudflare",
  "currentName": "Cloudflare",
  "providers": [{
    "id": "cloudflare",
    "name": "Cloudflare",
    "detail": "1.1.1.1, 1.0.0.1",
    "isCurrent": true
  }]
}
```

#### `PUT /api/dns`

_Set DNS provider_

Changes the global DNS provider.

**Body:**

```json
{ "provider": "cloudflare" }
```

| Valid providers | DNS Servers |
|---|---|
| system | OS/router default |
| cloudflare | 1.1.1.1, 1.0.0.1 |
| google | 8.8.8.8, 8.8.4.4 |
| quad9 | 9.9.9.9, 149.112.112.112 |
| adguard | 94.140.14.14, 94.140.15.15 |
| opendns | 208.67.222.222, 208.67.220.220 |
| cleanbrowsing | 185.228.168.9, 185.228.169.9 |
| nextdns | 45.90.28.0, 45.90.30.0 |

#### `POST /api/dns/probe`

_Measure DNS latency_

Measures the latency to a specific DNS provider in milliseconds.

**Body:**

```json
{ "provider": "cloudflare" }
```

**Response:**

```json
{
  "provider": "cloudflare",
  "latencyMs": 12,
  "reachable": true
}
```

#### `POST /api/dns/probe-all`

_Measure all DNS providers_

Tests latency for all DNS providers, sorted by fastest first.

**Response:**

```json
{
  "results": [
    { "provider": "cloudflare", "latencyMs": 12, "reachable": true },
    { "provider": "google", "latencyMs": 18, "reachable": true }
  ]
}
```

### Tor

| Method | Path | Description |
|---|---|---|
| `GET` | `/api/tor` | Full status — connected, circuit info, ports, errors |
| `PUT` | `/api/tor` | Toggle — { "enabled": true } |
| `POST` | `/api/tor/new-circuit` | Request new Tor identity |

#### `GET /api/tor`

_Full Tor status_

Returns comprehensive Tor status including connection state, SOCKS proxy details, and circuit info.

**Response:**

```json
{
  "enabled": true,
  "isConnected": true,
  "isConnecting": false,
  "circuitInfo": "Connected to Tor network",
  "socksHost": "127.0.0.1",
  "socksPort": 9150,
  "controlPort": 9151,
  "isTorInstalled": true,
  "errorMessage": null
}
```

#### `PUT /api/tor`

_Enable/disable Tor_

**Body:**

```json
{ "enabled": true }
```

#### `POST /api/tor/new-circuit`

Requests a new Tor circuit (new exit IP). Tor must be connected. Sends SIGNAL NEWNYM to the Tor control port.

**curl:**

```bash
curl -X POST -H "X-API-Key: YOUR_KEY" http://127.0.0.1:1306/api/tor/new-circuit
```

**Response:**

```json
{ "requested": true }
```

**Response example:** `{ "success": true }`

### Element Queries

| Method | Path | Description |
|---|---|---|
| `POST` | `/api/tabs/:id/element/visible` | Check visibility — { "selector": "..." } |
| `POST` | `/api/tabs/:id/element/enabled` | Check enabled — { "selector": "..." } |
| `POST` | `/api/tabs/:id/element/checked` | Check checked — { "selector": "..." } |
| `POST` | `/api/tabs/:id/element/hidden` | Check hidden — { "selector": "..." } |
| `POST` | `/api/tabs/:id/element/count` | Count matches — { "selector": "..." } |
| `POST` | `/api/tabs/:id/element/bounding-box` | Get bounding rect — { "selector": "..." } |
| `POST` | `/api/tabs/:id/element/inner-html` | Get innerHTML — { "selector": "..." } |
| `POST` | `/api/tabs/:id/element/inner-text` | Get innerText — { "selector": "..." } |
| `POST` | `/api/tabs/:id/element/text-content` | Get textContent — { "selector": "..." } |
| `POST` | `/api/tabs/:id/element/attribute` | Get attribute — { "selector": "...", "name": "..." } |
| `POST` | `/api/tabs/:id/element/screenshot` | Element screenshot (base64 PNG) — { "selector": "..." } |

#### `POST /api/tabs/:id/element/visible`

_Check if element is visible_

Check if an element is visible (has dimensions, not hidden/display:none).

**Body:**

```json
{ "selector": "#submit-btn" }
```

**Response:**

```json
{ "visible": true }
```

#### `POST /api/tabs/:id/element/enabled`

_Check if element is enabled_

Check if an element is not disabled.

**Body:**

```json
{ "selector": "button" }
```

**Parameters:** `{ "selector" }`

**Response example:** `{ "enabled": true }`

#### `POST /api/tabs/:id/element/checked`

_Check if checkbox/radio is checked_

Check the checked state of an input element.

**Parameters:** `{ "selector" }`

**Response example:** `{ "checked": false }`

#### `POST /api/tabs/:id/element/hidden`

_Check if element is hidden_

Inverse of visible — returns true if element has no dimensions or is hidden.

**Parameters:** `{ "selector" }`

**Response example:** `{ "hidden": false }`

#### `POST /api/tabs/:id/element/count`

_Count matching elements_

Count elements matching the selector.

**Response:**

```json
{ "count": 5 }
```

**Parameters:** `{ "selector" }`

#### `POST /api/tabs/:id/element/bounding-box`

_Get element bounding rectangle_

Returns x, y, width, height, top, right, bottom, left.

**Parameters:** `{ "selector" }`

**Response example:** `{ "x": 10, "y": 20, "width": 200, "height": 50 }`

#### `POST /api/tabs/:id/element/inner-html`

_Get element innerHTML_

Returns the innerHTML of the first matching element.

**Parameters:** `{ "selector" }`

#### `POST /api/tabs/:id/element/inner-text`

_Get element innerText_

Returns the rendered innerText of the element.

**Parameters:** `{ "selector" }`

#### `POST /api/tabs/:id/element/text-content`

_Get element textContent_

Returns the raw textContent of the element (includes hidden text).

**Parameters:** `{ "selector" }`

#### `POST /api/tabs/:id/element/attribute`

_Get element attribute_

Get a specific attribute value.

**Body:**

```json
{ "selector": "a", "name": "href" }
```

**Parameters:** `{ "selector", "name" }`

**Response example:** `{ "value": "https://..." }`

#### `POST /api/tabs/:id/element/screenshot`

_Screenshot a specific element_

Take a screenshot of a specific element (base64 PNG). macOS only.

**Parameters:** `{ "selector" }`

### Element Actions

| Method | Path | Description |
|---|---|---|
| `POST` | `/api/tabs/:id/hover` | Hover element — { "selector": "..." } |
| `POST` | `/api/tabs/:id/focus` | Focus element — { "selector": "..." } |
| `POST` | `/api/tabs/:id/dblclick` | Double-click — { "selector": "..." }. New target tabs are returned as openedTabId/openedTabs |
| `POST` | `/api/tabs/:id/press` | Press key — { "key": "Enter", "selector"? } |
| `POST` | `/api/tabs/:id/uncheck` | Uncheck checkbox — { "selector": "..." } |
| `POST` | `/api/tabs/:id/input-value` | Get an input/textarea/select value — { "selector" } → { "value" } |
| `POST` | `/api/tabs/:id/scroll-into-view` | Scroll element into view — { "selector" } |
| `POST` | `/api/tabs/:id/dispatch-event` | Dispatch a DOM event on an element — { "selector", "type": "click", "eventInit"? } |
| `POST` | `/api/tabs/:id/drag-and-drop` | Drag one element onto another — { "sourceSelector", "targetSelector" } |
| `POST` | `/api/tabs/:id/set-content` | Replace the page HTML — { "html", "baseURL"? } |
| `POST` | `/api/tabs/:id/add-script-tag` | Inject a <script> — { "url" } or { "content" } |
| `POST` | `/api/tabs/:id/add-style-tag` | Inject a <style>/<link> — { "url" } or { "content" } |
| `POST` | `/api/tabs/:id/add-init-script` | Run a script at document-start on every future navigation — { "script" } |
| `GET` | `/api/tabs/:id/content` | Get full page HTML |
| `GET` | `/api/tabs/:id/page-title` | Get page title |

#### `POST /api/tabs/:id/hover`

_Hover over element_

Dispatch pointerenter/mouseover/mouseenter events on element center.

#### `POST /api/tabs/:id/focus`

_Focus element_

Call el.focus() on the matched element.

**Parameters:** `{ "selector" }`

#### `POST /api/tabs/:id/dblclick`

_Double-click element_

Dispatch full mousedown/mouseup/click/dblclick sequence.

**Parameters:** `{ "selector" }`

**Response example:** `{ "success": true, "openedTabId": "uuid", "openedTabs": [...] }`

**Notes:** If the double-click opens a target=_blank/named-target/window.open tab, the response includes openedTabId/openedTabs.

#### `POST /api/tabs/:id/press`

_Press key on element_

Dispatch keydown/keypress/keyup on element or active element.

**Body:**

```json
{ "key": "Enter", "selector": "input" }
```

**Parameters:** `{ "key", "selector"? }`

#### `POST /api/tabs/:id/uncheck`

_Uncheck checkbox_

Set checked=false and dispatch change event.

**Parameters:** `{ "selector" }`

#### `POST /api/tabs/:id/input-value`

_Read an input/textarea/select value_

Playwright `inputValue()`. Body: `{ "selector": "#email" }` → `{ "value": "a@b.com" }`.

**Parameters:** `{ "selector" }`

**Response example:** `{ "success": true, "value": "a@b.com" }`

**Notes:** Playwright inputValue().

#### `POST /api/tabs/:id/scroll-into-view`

_Scroll an element into view_

Playwright `scrollIntoViewIfNeeded()`. Body: `{ "selector": "#footer" }`.

**Parameters:** `{ "selector" }`

**Notes:** Playwright scrollIntoViewIfNeeded().

#### `POST /api/tabs/:id/dispatch-event`

_Dispatch a DOM event on an element_

Playwright `dispatchEvent()`. Body: `{ "selector": "#btn", "type": "click", "eventInit": { } }`. eventInit is merged into the event.

**Parameters:** `{ "selector", "type", "eventInit"? }`

**Request example:** `{ "selector": "#btn", "type": "click" }`

**Notes:** Playwright dispatchEvent(). eventInit is merged into the event.

#### `POST /api/tabs/:id/drag-and-drop`

_Drag one element onto another_

Synthetic HTML5 drag (dragstart → dragover → drop → dragend). Body: `{ "sourceSelector": "#a", "targetSelector": "#b" }`.

**Parameters:** `{ "sourceSelector", "targetSelector" }`

**Notes:** Synthetic HTML5 drag (dragstart→dragover→drop→dragend).

#### `POST /api/tabs/:id/set-content`

_Replace the page's HTML_

Playwright `setContent()`. Body: `{ "html": "<h1>Hi</h1>", "baseURL": "https://example.com" }` (baseURL optional).

**Parameters:** `{ "html", "baseURL"? }`

**Request example:** `{ "html": "<h1>Hi</h1>" }`

**Notes:** Playwright setContent().

#### `POST /api/tabs/:id/add-script-tag`

_Inject a <script> tag_

Playwright `addScriptTag()`. Body: `{ "url": "https://..." }` or `{ "content": "window.__x=1" }`.

**Parameters:** `{ "url" } or { "content" }`

**Notes:** Playwright addScriptTag().

#### `POST /api/tabs/:id/add-style-tag`

_Inject a <style>/<link> tag_

Playwright `addStyleTag()`. Body: `{ "url": "https://..." }` or `{ "content": "body{background:red}" }`.

**Parameters:** `{ "url" } or { "content" }`

**Notes:** Playwright addStyleTag().

#### `POST /api/tabs/:id/add-init-script`

_Run a script at document-start on every future navigation_

Playwright `addInitScript()`. Body: `{ "script": "window.__init=true" }`. Applies to the next and subsequent page loads in this tab.

**Parameters:** `{ "script" }`

**Notes:** Playwright addInitScript(). Applies to the next and subsequent page loads in this tab.

#### `GET /api/tabs/:id/content`

Returns document.documentElement.outerHTML.

#### `GET /api/tabs/:id/page-title`

Returns the current tab title.

**Response example:** `{ "title": "Example" }`

### Keyboard Input

| Method | Path | Description |
|---|---|---|
| `POST` | `/api/tabs/:id/keyboard/press` | Press key — { "key": "...", "modifiers"?: ["Control","Shift"] } |
| `POST` | `/api/tabs/:id/keyboard/insert-text` | Insert text — { "text": "..." } |
| `POST` | `/api/tabs/:id/keyboard/down` | Key down — { "key": "..." } |
| `POST` | `/api/tabs/:id/keyboard/up` | Key up — { "key": "..." } |
| `POST` | `/api/tabs/:id/mouse/wheel` | Mouse wheel — { "deltaX"?, "deltaY"?, "x"?, "y"? } |

#### `POST /api/tabs/:id/keyboard/press`

_Press key with optional modifiers_

Dispatch keydown/keypress/keyup on the active element.

**Body:**

```json
{ "key": "a", "modifiers": ["Control"] }
```

#### `POST /api/tabs/:id/keyboard/insert-text`

_Insert text at cursor_

Insert text using execCommand or value setter.

**Parameters:** `{ "text" }`

#### `POST /api/tabs/:id/keyboard/down`

_Key down event_

Dispatch keydown only.

**Parameters:** `{ "key" }`

#### `POST /api/tabs/:id/keyboard/up`

_Key up event_

Dispatch keyup only.

**Parameters:** `{ "key" }`

#### `POST /api/tabs/:id/mouse/wheel`

_Mouse wheel scroll_

Dispatch WheelEvent.

**Body:**

```json
{ "deltaX": 0, "deltaY": 100 }
```

**Parameters:** `{ "deltaX"?, "deltaY"?, "x"?, "y"? }`

**Request example:** `{ "deltaY": 100 }`

### Native OS Input

| Method | Path | Description |
|---|---|---|
| `POST` | `/api/tabs/:id/native/click` | Native mouse click (isTrusted:true) — { "selector" } or { "x", "y" } + "button"?, "doubleClick"?. Headed: CGEvent to screen coords. Hidden/headless: NSEvent injected directly into WKWebView. New target tabs are returned as openedTabId/openedTabs |
| `POST` | `/api/tabs/:id/native/type` | Native keyboard typing (isTrusted:true) — { "text": "...", "selector"?, "delay"?: 50 }. NSEvent key events injected directly into the tab's WKWebView in all modes — no window activation, focus stealing, or Accessibility permission. Newlines are sent as Enter presses. With a selector, focus is verified (trusted-click retry for React/contenteditable editors) and reported back as "focused" |
| `POST` | `/api/tabs/:id/native/press` | Native key press (isTrusted:true) — { "key": "Enter", "modifiers"?: ["Control"] }. Supports all keys (Enter, Tab, Escape, arrows, F1-F12, letters, digits) and modifier combos (Command, Control, Shift, Option). NSEvent injected directly into the tab's WKWebView in all modes |
| `POST` | `/api/tabs/:id/native/paste` | Native paste (isTrusted:true) — { "text": "...", "html"?, "selector"? }. Pasteboard + Cmd+V injected directly into the tab's WKWebView in all modes; the user's clipboard is saved and restored, and pasteboard access is serialized so concurrent instances can't collide or hang |
| `POST` | `/api/tabs/:id/native/move` | Native mouse move — { "x", "y" } or { "selector" } + "steps"?, "duration"?. Interpolated movement via OS events (headed) or JS mousemove (hidden/headless) |

#### `POST /api/tabs/:id/native/click`

_Native mouse click (isTrusted:true)_

Click an element or coordinates using real OS input events. The element is scrolled into view first. In headed mode, the window is activated and a CGEvent mouse click is posted at screen coordinates. In hidden/headless mode, an NSEvent is injected directly into the WKWebView (still isTrusted:true). Both modes work reliably for concurrent instances. New target tabs are returned as `openedTabId`/`openedTabs`.

**Body:**

```json
{ "selector": "#submit-btn" }
// or
{ "x": 150, "y": 300, "button": "left", "doubleClick": false }
```

**Response:**

```json
{ "success": true, "native": true, "x": 150, "y": 300, "screenX": 650, "screenY": 400 }
```

**Curl:**

```bash
curl -X POST -H "X-API-Key: YOUR_KEY" -H "Content-Type: application/json" \
  -d '{"selector": "#submit-btn"}' \
  http://127.0.0.1:1306/api/tabs/TAB_ID/native/click
```

#### `POST /api/tabs/:id/native/type`

_Native keyboard typing_

Type text character by character using real keyboard events injected directly into the tab's WKWebView — identical in headed, hidden and headless modes. Each keystroke produces `isTrusted: true`. No window activation, focus stealing or Accessibility permission is involved, so keystrokes can never land in the URL bar or another app, and no per-key system sounds fire. Newlines are sent as Enter key presses. The optional selector focuses the element first and verifies that an editable element took focus (retrying with a trusted native click for React/contenteditable editors); the result is reported as `focused`, with a `warning` when nothing editable is focused. Delay controls milliseconds between keystrokes.

**Body:**

```json
{ "text": "hello world", "selector": "#search", "delay": 50 }
```

**Response:**

```json
{ "success": true, "native": true, "length": 11, "method": "webview-event", "focused": true }
```

**Parameters:** `{ "text", "selector"?, "delay"?: 50 }`

**Notes:** All modes: NSEvent key events injected directly into the tab's WKWebView (isTrusted:true) — no window activation, focus stealing, system beeps, or Accessibility permission. Newlines in text are sent as Enter presses. With a selector, focus is verified (retrying with a trusted click for React/contenteditable editors) and reported back as focused; a warning is included when no editable element took focus. Delay is milliseconds between keystrokes.

#### `POST /api/tabs/:id/native/press`

_Native key press with modifiers_

Press a key with optional modifiers using a real keyboard event injected directly into the tab's WKWebView (`isTrusted: true`, identical in all modes — no window activation or Accessibility permission). Supports: Enter, Tab, Escape, Backspace, Space, Arrow keys, F1-F12, Home, End, PageUp, PageDown, all letters and digits. Modifiers: Command, Control, Shift, Alt/Option.

**Body:**

```json
{ "key": "Enter", "modifiers": ["Command"] }
```

**Response:**

```json
{ "success": true, "native": true, "key": "Enter", "modifiers": ["Command"], "method": "webview-event" }
```

**Parameters:** `{ "key", "modifiers"?, "selector"? }`

**Notes:** All modes: NSEvent injected directly into the tab's WKWebView (isTrusted:true) — no window activation or Accessibility permission. Supports: Enter, Tab, Escape, Backspace, Space, Arrow keys, F1-F12, Home, End, PageUp, PageDown, all letters and digits. Modifiers: Command, Control, Shift, Alt/Option.

#### `POST /api/tabs/:id/native/paste`

_Native paste via clipboard + Cmd+V_

Sets the system clipboard with text (and optionally HTML), then injects a real Cmd+V keystroke directly into the tab's WKWebView (`isTrusted: true`, identical in all modes — no window activation). Your clipboard is saved and restored automatically, and pasteboard access is serialized so 20+ concurrent instances can't collide or hang. The optional selector focuses the element first and reports `focused` back.

**Body:**

```json
{ "text": "Hello", "html": "<b>Hello</b>", "selector": ".editor" }
```

**Response:**

```json
{ "success": true, "native": true, "length": 5, "hasHtml": true, "method": "webview-paste", "focused": true }
```

**Parameters:** `{ "text", "html"?, "selector"? }`

**Notes:** All modes: pasteboard + NSEvent Cmd+V injected directly into the tab's WKWebView (isTrusted:true). The user's clipboard is saved and restored automatically, and pasteboard access is serialized so concurrent instances can't collide or hang. With a selector, focus is verified and reported back as focused.

#### `POST /api/tabs/:id/native/move`

_Native mouse move with interpolation_

Move the mouse cursor using OS events with smooth interpolation (ease-in-out). Steps controls intermediate points, duration is total time in ms.

**Body:**

```json
{ "selector": "#target", "steps": 10, "duration": 200 }
// or
{ "x": 300, "y": 400 }
```

**Response:**

```json
{ "success": true, "native": true, "x": 300, "y": 400 }
```

**Parameters:** `{ "x", "y" } or { "selector" } + "steps"?, "duration"?`

**Notes:** Headed: OS events with smooth ease-in-out. Hidden/headless: JS mousemove events. Steps controls intermediate points, duration is total time in ms.

### localStorage / sessionStorage

| Method | Path | Description |
|---|---|---|
| `POST` | `/api/tabs/:id/storage/local/get` | Get localStorage — { "key": "..." } |
| `POST` | `/api/tabs/:id/storage/local/set` | Set localStorage — { "key": "...", "value": "..." } |
| `POST` | `/api/tabs/:id/storage/local/remove` | Remove localStorage — { "key": "..." } |
| `POST` | `/api/tabs/:id/storage/local/clear` | Clear all localStorage |
| `POST` | `/api/tabs/:id/storage/session/get` | Get sessionStorage — { "key": "..." } |
| `POST` | `/api/tabs/:id/storage/session/set` | Set sessionStorage — { "key": "...", "value": "..." } |
| `POST` | `/api/tabs/:id/storage/session/remove` | Remove sessionStorage — { "key": "..." } |
| `POST` | `/api/tabs/:id/storage/session/clear` | Clear all sessionStorage |

#### `POST /api/tabs/:id/storage/local/get`

_Get localStorage value_

Get a value from localStorage.

**Body:**

```json
{ "key": "token" }
```

#### `POST /api/tabs/:id/storage/local/set`

_Set localStorage value_

**Body:**

```json
{ "key": "token", "value": "abc123" }
```

**Parameters:** `{ "key", "value" }`

#### `POST /api/tabs/:id/storage/local/remove`

_Remove localStorage key_

Remove a key from localStorage.

**Parameters:** `{ "key" }`

#### `POST /api/tabs/:id/storage/local/clear`

Clear all localStorage entries.

#### `POST /api/tabs/:id/storage/session/get`

_Get sessionStorage value_

Get a value from sessionStorage.

**Parameters:** `{ "key" }`

#### `POST /api/tabs/:id/storage/session/set`

_Set sessionStorage value_

Set a value in sessionStorage.

**Parameters:** `{ "key", "value" }`

#### `POST /api/tabs/:id/storage/session/remove`

_Remove sessionStorage key_

Remove a key from sessionStorage.

**Parameters:** `{ "key" }`

#### `POST /api/tabs/:id/storage/session/clear`

Clear all sessionStorage entries.

### Wait Functions

| Method | Path | Description |
|---|---|---|
| `POST` | `/api/tabs/:id/wait-for-function` | Wait for expression — { "expression": "...", "timeout"?: 30000, "pollingInterval"?: 100 } |
| `POST` | `/api/tabs/:id/wait-for-timeout` | Wait for duration — { "timeout": 1000 } |
| `POST` | `/api/tabs/:id/wait-for-navigation` | Wait for navigation — { "timeout"?: 30000 } |
| `POST` | `/api/tabs/:id/wait-for-load-state` | Wait for load state — { "state"?: "load", "timeout"?: 30000 } |
| `POST` | `/api/tabs/:id/wait-for-url` | Wait for URL match — { "url": "...", "timeout"?: 30000 } |

#### `POST /api/tabs/:id/wait-for-function`

_Wait for JS expression to be truthy_

Polls a JS expression until it returns a truthy value. Max timeout: 300s.

**Body:**

```json
{ "expression": "document.querySelector('.loaded')", "timeout": 30000, "pollingInterval": 100 }
```

#### `POST /api/tabs/:id/wait-for-timeout`

_Wait for specified duration_

Simple sleep/delay. Max: 300s.

**Body:**

```json
{ "timeout": 2000 }
```

**Parameters:** `{ "timeout" }`

#### `POST /api/tabs/:id/wait-for-navigation`

_Wait for URL to change and page to load_

Polls until the tab URL changes and loading completes.

**Parameters:** `{ "timeout"? }`

**Response example:** `{ "navigated": true, "url": "..." }`

#### `POST /api/tabs/:id/wait-for-load-state`

_Wait for page load state_

Wait for "load", "domcontentloaded", or "networkidle" state.

**Body:**

```json
{ "state": "domcontentloaded" }
```

**Parameters:** `{ "state"?, "timeout"? }`

#### `POST /api/tabs/:id/wait-for-url`

_Wait for URL to match pattern_

Polls until the tab URL contains the given pattern.

**Body:**

```json
{ "url": "dashboard" }
```

**Parameters:** `{ "url", "timeout"? }`

### Frames

| Method | Path | Description |
|---|---|---|
| `GET` | `/api/tabs/:id/frames` | List all frames |
| `POST` | `/api/tabs/:id/frames/:index/execute` | Execute JS in frame — { "script": "..." } — returns 403 for cross-origin frames |
| `POST` | `/api/tabs/:id/frames/:index/click` | Click element in frame — { "selector": "..." } — returns 403 for cross-origin frames |

#### `GET /api/tabs/:id/frames`

Enumerate all frames with index, name, and URL. Cross-origin frames show "(cross-origin)".

#### `POST /api/tabs/:id/frames/:index/execute`

_Execute JS in frame_

Run JavaScript in a specific same-origin frame by index. Returns `403` for cross-origin frames.

**Parameters:** `{ "script" }`

**Notes:** Returns 403 for cross-origin frames

#### `POST /api/tabs/:id/frames/:index/click`

_Click element in frame_

Click an element within a same-origin child frame. Returns `403` for cross-origin frames.

**Parameters:** `{ "selector" }`

**Notes:** Returns 403 for cross-origin frames

### Accessibility

| Method | Path | Description |
|---|---|---|
| `GET` | `/api/tabs/:id/accessibility` | Accessibility tree snapshot (DOM roles, labels, text) |

#### `GET /api/tabs/:id/accessibility`

_Accessibility tree snapshot_

Walk the DOM tree and return roles, aria labels, text content, IDs, and class names as a nested JSON tree.

### Emulation

| Method | Path | Description |
|---|---|---|
| `PUT` | `/api/tabs/:id/emulation/geolocation` | Override geolocation — { "latitude", "longitude", "accuracy"? } |
| `PUT` | `/api/tabs/:id/emulation/timezone` | Override timezone — { "timezoneId": "America/New_York" } |
| `PUT` | `/api/tabs/:id/emulation/locale` | Override locale — { "locale": "fr-FR" } |
| `PUT` | `/api/tabs/:id/emulation/color-scheme` | Set color scheme — { "colorScheme": "dark" } |

#### `PUT /api/tabs/:id/emulation/geolocation`

_Override geolocation_

Override navigator.geolocation for the tab.

**Body:**

```json
{ "latitude": 40.7128, "longitude": -74.006, "accuracy": 100 }
```

#### `PUT /api/tabs/:id/emulation/timezone`

_Override timezone_

Override Intl.DateTimeFormat timezone.

**Body:**

```json
{ "timezoneId": "America/New_York" }
```

**Parameters:** `{ "timezoneId" }`

#### `PUT /api/tabs/:id/emulation/locale`

_Override locale_

Override navigator.language and navigator.languages.

**Body:**

```json
{ "locale": "fr-FR" }
```

**Parameters:** `{ "locale" }`

#### `PUT /api/tabs/:id/emulation/color-scheme`

_Set color scheme_

Force light or dark mode for the page.

**Body:**

```json
{ "colorScheme": "dark" }
```

**Parameters:** `{ "colorScheme" }`

**Notes:** Must be 'light' or 'dark'

### Dialog Handling

| Method | Path | Description |
|---|---|---|
| `PUT` | `/api/tabs/:id/dialog/config` | Configure auto-response — { "action": "accept"/"dismiss"/"none", "promptText"? }. Dialogs never block the API: accept/dismiss resolve instantly; a visible window shows a non-blocking sheet (also resolvable here); tabs nobody can see (headless/hidden/API-owned) are auto-dismissed unless action is "none", which queues them for /dialog/accept or /dialog/dismiss. dialog.opened / dialog.closed events are broadcast. |
| `GET` | `/api/tabs/:id/dialog` | Get pending dialog status |
| `POST` | `/api/tabs/:id/dialog/accept` | Accept dialog — { "promptText"? } |
| `POST` | `/api/tabs/:id/dialog/dismiss` | Dismiss dialog |

#### `PUT /api/tabs/:id/dialog/config`

_Configure auto-response for dialogs_

**Dialogs never block the API.** `accept`/`dismiss` resolve instantly; a tab in a visible window shows a non-blocking sheet that `/dialog/accept` and `/dialog/dismiss` can also resolve; tabs nobody can see (headless, hidden, API-owned) are auto-dismissed unless the action is `none`, which queues the dialog for the API (120 s timeout). `dialog.opened` / `dialog.closed` WebSocket events are broadcast either way.

Set how alerts/confirms/prompts are handled.

**Body:**

```json
{ "action": "accept", "promptText": "yes" }
```

#### `GET /api/tabs/:id/dialog`

_Check for pending dialog_

Check if there is a dialog waiting for resolution.

**Response example:** `{ "pending": true, "type": "confirm", "message": "..." }`

#### `POST /api/tabs/:id/dialog/accept`

_Accept pending dialog_

Accept the pending dialog (OK/Yes). Optionally provide promptText for prompt dialogs.

**Parameters:** `{ "promptText"? }`

#### `POST /api/tabs/:id/dialog/dismiss`

_Dismiss pending dialog_

Dismiss the pending dialog (Cancel/No).

### File Chooser

| Method | Path | Description |
|---|---|---|
| `PUT` | `/api/tabs/:id/file-chooser/config` | Configure picker handling — { "action": "native"/"intercept"/"accept"/"dismiss", "paths"? } |
| `GET` | `/api/tabs/:id/file-chooser` | Pending native file picker status |
| `POST` | `/api/tabs/:id/file-chooser/accept` | Complete picker with filesystem paths — { "paths": ["/abs/video.mp4"] } |
| `POST` | `/api/tabs/:id/file-chooser/dismiss` | Cancel pending picker |

#### `PUT /api/tabs/:id/file-chooser/config`

_Configure native file picker handling_

`native` shows NSOpenPanel (default in headed GUI). `intercept` queues the picker for the API. `accept` auto-completes with `paths`. `dismiss` cancels. Hidden/headless always intercept.

**Body:**

```json
{ "action": "intercept" }
```

**Parameters:** `{ "action", "paths"? }`

**Notes:** action: native (show NSOpenPanel), intercept (queue for API), accept (auto-complete with paths), dismiss

#### `GET /api/tabs/:id/file-chooser`

_Pending native file picker_

Returns `pending`, `stagedCount`, `armed`, and `action`.

**Response example:** `{ "pending": true, "stagedCount": 1, "armed": true, "action": "native" }`

#### `POST /api/tabs/:id/file-chooser/accept`

_Complete pending picker with files_

Body: `{ "paths": ["/abs/video.mp4"] }`. Same effect as `/upload` when a picker is already open.

**Parameters:** `{ "paths" }`

**Request example:** `{ "paths": ["/Users/me/video.mp4"] }`

**Notes:** Same as /upload when a picker is already open.

#### `POST /api/tabs/:id/file-chooser/dismiss`

Dismiss the native file chooser so WebKit is not left waiting.

**Notes:** Unblocks WebKit if a picker was intercepted and you do not want to attach a file.

### Network Monitoring

| Method | Path | Description |
|---|---|---|
| `POST` | `/api/tabs/:id/network/enable` | Start intercepting fetch/XHR requests |
| `GET` | `/api/tabs/:id/network/log` | Get network log (?clear=true optional) |
| `POST` | `/api/tabs/:id/network/disable` | Stop network monitoring |

#### `POST /api/tabs/:id/network/enable`

Monkey-patches fetch() and XMLHttpRequest to log all requests with URL, method, status, and timing.

#### `GET /api/tabs/:id/network/log`

_Get network request log_

Returns logged requests. Use ?clear=true to clear the log after reading.

**Parameters:** `Query: ?clear=true`

**Notes:** Returns URL, method, status, timing for each request

#### `POST /api/tabs/:id/network/disable`

Clear network log and disable monitoring.

### Console Capture

| Method | Path | Description |
|---|---|---|
| `POST` | `/api/tabs/:id/console/enable` | Start capturing console.log/warn/error/info |
| `GET` | `/api/tabs/:id/console/log` | Get console entries (?clear=true&level= optional) |
| `POST` | `/api/tabs/:id/console/disable` | Stop console capture |

#### `POST /api/tabs/:id/console/enable`

_Start capturing console output_

Intercepts console.log, warn, error, and info. Also captures uncaught JS errors.

#### `GET /api/tabs/:id/console/log`

_Get console entries_

Returns captured console entries. Filter with ?level=error or clear with ?clear=true.

**Parameters:** `Query: ?clear=true&level=error`

**Notes:** Filter by level: log, warn, error, info

#### `POST /api/tabs/:id/console/disable`

Clear console log and stop capturing.

### Tracing

| Method | Path | Description |
|---|---|---|
| `POST` | `/api/tracing/start` | Start trace — { "tabId"?, "screenshots"?, "network"? } |
| `POST` | `/api/tracing/stop` | Stop trace — { "traceId": "..." } |

#### `POST /api/tracing/start`

_Start recording trace_

Start a trace session. Returns a traceId to use with stop.

**Body:**

```json
{ "tabId": "...", "screenshots": true, "network": true }
```

#### `POST /api/tracing/stop`

_Stop trace and get results_

Stop the trace and return all recorded actions with timestamps.

**Body:**

```json
{ "traceId": "..." }
```

**Parameters:** `{ "traceId" }`

**Notes:** Returns all recorded actions with timestamps

## WebSocket events

Connect to `ws://127.0.0.1:1306/api/events?apiKey=YOUR_KEY` (or send `X-API-Key` on the upgrade request). Each message is JSON: `{ "type": "…", "data": { … }, "timestamp": "ISO-8601" }`. Client frames are accepted (ping → pong, close handshake); text frames are ignored.

| Event | Data | When |
|---|---|---|
| `tab.created` | `tabId`, `windowId`, `url`, `title` | A tab was opened — including tabs created by `target="_blank"` links or `window.open()` during API clicks |
| `tab.closed` | `tabId`, `windowId` | A tab was closed |
| `tab.activated` | `tabId`, `windowId` | The active tab changed |
| `tab.updated` | `tabId`, `windowId`, `title` | Tab title changed (debounced 300 ms) |
| `navigation.changed` | `tabId`, `windowId`, `url` | URL changed in a tab |
| `page.loading` / `page.loaded` | `tabId`, `windowId` | Page started / finished loading |
| `page.progress` | `tabId`, `windowId`, `progress` (0–1) | Load progress (throttled 250 ms) |
| `page.crashed` | `tabId`, `url`, `willReload` | The tab's web content process terminated; Vela reloads up to three times per 30 s, then sets `hasCrashed` (cleared by `/reload`) |
| `dialog.opened` / `dialog.closed` | `tabId`, `type`, `message` / `accepted` | A JavaScript dialog appeared / was resolved |
| `filechooser.opened` / `filechooser.closed` | `tabId`, … | Native file picker intercepted / completed or cancelled |

**JavaScript Example:**

```javascript
const ws = new WebSocket('ws://127.0.0.1:1306/api/events?apiKey=YOUR_KEY');

ws.onmessage = (event) => {
  const msg = JSON.parse(event.data);
  console.log(msg.type, msg.data);
};

ws.onopen = () => console.log('Connected');
ws.onclose = () => console.log('Disconnected');
```

**Python Example:**

```python
import asyncio, websockets, json

async def listen():
    uri = "ws://127.0.0.1:1306/api/events?apiKey=YOUR_KEY"
    async with websockets.connect(uri) as ws:
        async for message in ws:
            event = json.loads(message)
            print(f"{event['type']}: {event['data']}")

asyncio.run(listen())
```

## Hidden & headless mode (from the web docs)

Run Vela without a visible UI on macOS — no windows, no Dock icon, just the full Automation API. Perfect for CI/CD pipelines, automated testing, web scraping, and server-side browser control.

#### Hidden Mode (Recommended)

Runs a **full headed browser** with real GUI rendering, but all windows are invisible. Sessions, cookies, and localStorage persist normally. Websites cannot detect hidden mode since it uses the exact same rendering pipeline as headed mode.

```bash
open -n -g -a Vela --args --hidden
```

The `-g` flag prevents bringing the app to the foreground. Hidden mode defaults to port **1307** (GUI uses 1306) so both APIs run without conflict. Sessions and cookies persist exactly like headed mode.

#### Headless Mode (Persistent)

Runs without any visible UI — no dock icon, no windows. WebKit is pre-warmed for fast startup. When a profile is used, sessions are fully persistent (cookies, localStorage, etc.) — like Chrome's headless mode. Without a profile, uses non-persistent storage (ephemeral sessions).

```bash
open -n -a Vela --args --headless
```

Or run the binary directly:

```bash
/Applications/Vela.app/Contents/MacOS/Vela --headless
```

#### Optional Flags

| Flag | Meaning |
|---|---|
| --port <number> | Override API port (default: 1307 hidden/headless, 1306 normal) |
| --bind <address> | Override bind address (default: 127.0.0.1). Use `0.0.0.0` for network access. |

#### Stdout Output

When launched, connection details are printed to stdout for easy parsing:

```bash
Vela Hidden Mode
API: http://127.0.0.1:1307
Key: aBcDeFgHiJkLmNoPqRsTuVwXyZ012345
```

#### Example: Hidden Mode Automation

```bash
# 1. Launch hidden (alongside GUI with -n -g)
open -n -g -a Vela --args --hidden

# 2. Create a tab and navigate
curl -X POST -H "X-API-Key: YOUR_KEY" \
  -H "Content-Type: application/json" \
  -d '{"url":"https://example.com"}' \
  http://127.0.0.1:1307/api/tabs

# 3. Wait for page load
curl -X POST -H "X-API-Key: YOUR_KEY" \
  -H "Content-Type: application/json" \
  -d '{"state":"load"}' \
  http://127.0.0.1:1307/api/tabs/TAB_ID/wait-for-load-state

# 4. Take a screenshot
curl -H "X-API-Key: YOUR_KEY" \
  http://127.0.0.1:1307/api/tabs/TAB_ID/screenshot

# 5. Get visible text
curl -H "X-API-Key: YOUR_KEY" \
  http://127.0.0.1:1307/api/tabs/TAB_ID/text
```

#### Python Example

```bash
import subprocess, requests, time

# Launch Vela hidden (use -n -g to run alongside GUI without focus)
proc = subprocess.Popen(
    ["open", "-n", "-g", "-a", "Vela", "--args", "--hidden"],
    stdout=subprocess.PIPE, text=True
)
time.sleep(2)  # Wait for server to start

API = "http://127.0.0.1:1307"  # Hidden/headless defaults to port 1307
KEY = "YOUR_API_KEY"
headers = {"X-API-Key": KEY, "Content-Type": "application/json"}

# Create tab and navigate
tab = requests.post(f"{API}/api/tabs",
    json={"url": "https://example.com"}, headers=headers).json()

# Wait for load
requests.post(f"{API}/api/tabs/{tab['id']}/wait-for-load-state",
    json={"state": "load"}, headers=headers)

# Get page text
text = requests.get(f"{API}/api/tabs/{tab['id']}/text",
    headers=headers).json()
print(text["text"])
```

Note: All 200+ API endpoints work identically in hidden, headless, and normal mode. Stop with `pkill -f "Vela.*--hidden"`, `pkill -f "Vela.*--headless"`, or `kill <pid>`.

## Data models

**Tab** (`GET /api/tabs`, `GET /api/tabs/:id`, `POST /api/tabs`):
`id`, `windowId`, `title`, `url`, `isLoading`, `loadProgress` (0–1), `canGoBack`, `canGoForward`, `isSecure`, `isPinned`, `isMuted`, `isPlayingAudio`, `isPrivate`, `profileId`, `isSuspended`, `hasCrashed`.

**Window** (`GET /api/windows`, `GET /api/windows/:id`): `id`, `tabCount`, `isPrivate`, `profileId`, `currentTabId` (+ `tabs` on the single-window endpoint).

**Profile** (`GET /api/profiles`, `GET /api/profiles/:id`, export): `id`, `name`, `icon`, `color`, `position`, `fingerprintSeed`, `fingerprintEnabled`, `userAgentId`, `dnsProvider`, `contentBlockerEnabled`, `blockTrackers`, `blockAds`, `blockPopups`, `extraBlockTrackers`, `extraBlockAds`, `automaticCookieConsent`, `httpsFirstEnabled`, `torEnabled`, `autofillEnabled`, `savePasswordEnabled`, `realBrowserEnabled`, `spoofLanguage`, `spoofTimezone`, `createdAt`, `modifiedAt`, optional `proxyConfig` (`type` http|socks5, `host`, `port`, `username`, `password`, `isEnabled`, `selectionMode`, `proxyListCount`, `activeProxy`) and `sessionCookies[]`. `null` means "use the global setting". The same field names are accepted by `POST /api/profiles`, `PUT /api/profiles/:id` and `POST /api/profiles/import`.

**Cookie** (tab/profile cookie endpoints): `name`, `value`, `domain`, `path`, `isSecure`, `isHttpOnly`, `expiresDate` (ISO-8601 or null).

**Download**: `id`, `fileName`, `url`, `state`, `progress`, `totalBytes`, `downloadedBytes`.

**History entry**: `id`, `title`, `url`, `visitCount`, `lastVisitedAt`. **Bookmark**: `id`, `title`, `url`, `folderId`, `position`, `createdAt`. **Folder**: `id`, `name`, `parentId`, `position`.

## Security notes

- The API gives full control of the browser, its profiles, cookies and saved logins (`/api/passwords/:id` additionally requires `X-Password-Access: true`). Treat the key like a password.
- Keep the bind address at `127.0.0.1`. Binding to `0.0.0.0` exposes the API to your network; there is no TLS.
- Requests whose `Host`/`Origin` are not local are refused (403), which blocks browser-based attacks from web pages running on the same machine.
- Bot user agents (Googlebot etc.) are flagged unsafe for login; `/login` refuses to submit forms with such a UA.
- `PUT /api/settings/:key` can change any `vela_*` preference — including the automation port — validate what you write.
