Oya Browser docs

# A real browser for your agents.

Your agent does a task once in plain language. Oya saves it as Playwright code and replays it with no model, signed in like your staff and never flagged as a bot. Start with the quickstart: three steps, three minutes.

## Quickstart

Three steps, about three minutes. You end with a task your code replays with no model.

### Get an API key

Open the [console](https://oyabrowser.com/dashboard), create a key from the API key menu, and export it.

```
export OYA_API_KEY=oya_...
```

### Install the SDK

```
npm i @oya-ai/browser
```

### Run a task, save it, replay it

```
import { Oya } from "@oya-ai/browser";

const oya = new Oya();                          // reads OYA_API_KEY
const browser = await oya.browser.start();      // a real browser, not headless Chrome

try {
  // 1. Do the task once, in plain language. The model works it out.
  await browser.ask(
    "On https://httpbin.org/forms/post order a medium pizza for {{name}} and submit it.",
    { data: { name: "Ada Lovelace" } },
  );

  // 2. Save that run as a playbook: Playwright steps, with your values as variables.
  const playbook = await browser.toPlaybook("pizza-order");
  console.log(playbook.variables);              // the inputs play() takes

  // 3. Replay it with new values. No model, no tokens.
  console.log(await browser.play("pizza-order", { name: "Grace Hopper" }));
} finally {
  await browser.close();                        // stop paying for it, even on an error
}
```

That is the loop: **ask once, replay forever**. From here, read Playbooks for variables, free-text fields and healing, or MCP to hand the browser to Claude or Cursor.

### From a terminal instead

```
npm i -g @oya-ai/cli
oya login && oya start
oya ask "find the pricing page on example.com"
```

Everything belongs to the API key: browsers, personas, cookies, playbooks, settings and usage. One key never sees another's. Which provider runs the browser is a setting on the key, chosen during onboarding, so your code never changes.

## API keys

Go to the [dashboard](https://oyabrowser.com/dashboard). Open the API key menu to create or select a key for your workspace.

Your key is scoped: you only see browsers connected with your key. Other users' browsers are invisible to you.

Save your key somewhere safe. If you lose it, you'll need to generate a new one. The old key still works for any browsers already connected with it.

## Desktop sign-in

For browsers on Oya infrastructure, the desktop app is a one-time step: log into the sites your agents need, and those cookies move to the remote browsers, which run the same fingerprint as that identity. The agent arrives already signed in, and the site sees one device returning rather than a fleet sharing an account.

Onboarding and Settings both have an **Open the desktop browser** button. It builds an `oya://` link carrying a single-use pairing code, never your API key, because a protocol URL is reachable by any page you visit and lands in OS logs on the way. The app exchanges that code over HTTPS with the server the link names.

The desktop app asks before connecting, naming the destination host, with Cancel as the default. Connecting shares that browser's cookies and logged-in sessions with the control plane it dials, so if a web page opened the dialog rather than your own dashboard, cancel it.

| Platform | Download |
| --- | --- |
| macOS (Intel + Apple Silicon) | [Oya Browser.dmg](https://oyabrowser.com/downloads/Oya.Browser-1.0.139-universal.dmg) |
| Windows (x64) | [Oya Browser.exe](https://oyabrowser.com/downloads/Oya.Browser-1.0.139-x64.exe) |
| Linux (x64) | [Oya Browser.AppImage](https://oyabrowser.com/downloads/Oya.Browser-1.0.139-x64.AppImage) |

**macOS:** Open the .dmg and drag the app to Applications. The build is signed and notarized, so it opens normally. If an older download is blocked, right-click the app → Open → Open.

**Linux:** `chmod +x` the AppImage and run it.

### Running multiple instances

To open multiple browser windows (e.g. different accounts or different API keys):

```
# macOS, open another instance
open -n "/Applications/Oya Browser.app"

# With separate sessions (own cookies, own config)
open -n "/Applications/Oya Browser.app" --args --user-data-dir=/tmp/oya-2
open -n "/Applications/Oya Browser.app" --args --user-data-dir=/tmp/oya-3

# Linux
./Oya-Browser.AppImage --user-data-dir=/tmp/oya-2
```

Each `--user-data-dir` gets its own cookies, logins, and config, fully isolated sessions.

## Connect a desktop browser

Open Oya Browser. The setup screen appears on first launch.

| Field | Value |
| --- | --- |
| Server URL | `wss://oyabrowser.com/ws` |
| API Key | The key you generated in the dashboard |
| Browser Name | Optional, how it shows in the dashboard |

Click **Connect**. The green dot in the toolbar confirms the connection. Your browser now appears in the [dashboard](https://oyabrowser.com/dashboard).

## Playbooks

A playbook is a task the agent did once, kept as Playwright steps. Replaying it runs those steps with no model in the loop, so it is fast, costs no tokens and does the same thing every time.

```
await browser.ask("Check eligibility for member {{memberId}}", {
  data: { memberId: "W2847-1193" },
});
const playbook = await browser.toPlaybook("eligibility-check");

// Later, on every case:
const result = await browser.play("eligibility-check", { memberId: "W5512-0042" });
// { steps: 7, total: 7, fellBack: false }
```

In the console the same loop is a button: run a prompt, then press **Save as playbook**.

### Variables and secrets

Put values in the prompt as `{{name}}`. Anything in `data` the agent can read; anything in `secrets` it never sees. Either way it types them through the placeholder, so the saved playbook keeps names, not values.

```
await browser.ask("Sign in as {{user}} with {{password}}, then open claims", {
  data: { user: "ops@clinic.example" },
  secrets: { password: process.env.PORTAL_PASSWORD },
});
```

`playbook.variables` lists the inputs and `playbook.defaults` what the run used, so `play(name)` with nothing repeats it and you pass only what changes. A secret has no default: it never left the page.

### Free-text fields

A comment box or a question to answer should not be the same text every run. Oya spots these fields and leaves them to your model, one short call per field, listed in `playbook.answers`. The generated code reads:

```
await page.getByLabel("Reason for visit").fill(
  vars["reason"] ?? (await oya.llm.answer("Reason for visit", vars)),
);
```

Pass the field's key in `play()` to type fixed text instead. A playbook without such a field makes no model call at all.

### When a page changes

If a step no longer fits the page, the agent finishes the task from there and its fix replaces the broken steps, so the next replay runs clean. The result says `healed: true`. Pass `{ autoHeal: false }` to throw the step's error instead.

### Signing in on replay

A replay that lands on a login page signs in with the persona's stored login and second factor, then carries on. Store them once; they are sealed at rest and never read back. See MFA for the code types.

```
await oya.personas.setCredentials(personaId, {
  domain: "portal.payer.example",
  username: "ops@clinic.example",
  password: process.env.PORTAL_PASSWORD,
});
await oya.personas.setMfa(personaId, { type: "totp", secret: process.env.PORTAL_TOTP });
```

### The Playwright code

`playbook.code` is the same flow as a Playwright module you own. Read it in review, keep it in git, or run it on any Playwright page:

```
// playbook.code, saved as eligibility-check.js:
// export default async function run(page, vars, oya) { ... }
import run from "./eligibility-check.js";

await run(page, { memberId: "W5512-0042" }, oya);
```

### Export and import

Move a playbook from staging to production, or from the cloud to your own deployment. Secrets travel by name only; set their values where it lands.

```
const doc = await staging.playbooks.export("eligibility-check");
await production.playbooks.import(doc, { overwrite: true });
```

```
oya playbooks export eligibility-check --out eligibility-check.json
oya playbooks import eligibility-check.json --replace
```

### Background runs

For long queues, submit a replay and hear back. `onHumanAttention` fires for a CAPTCHA or code nobody could solve; the run waits until you `respond()`.

```
await browser.submit({ playbook: "eligibility-check" }, {
  data: { memberId: "W5512-0042" },
  onSuccess: (result) => save(result),
  onHealed: (result) => notify("the portal changed; the playbook was fixed"),
  onHumanAttention: async (request) => { /* finish by hand */ await request.respond("done"); },
});
```

| Method | Endpoint | What it does |
| --- | --- | --- |
| POST | `/api/browsers/:id/playbooks` | Save the browser’s last run: { name } |
| POST | `/api/browsers/:id/playbooks/:name/play` | Replay: { variables, autoHeal } |
| GET | `/api/playbooks` | Every saved playbook |
| GET | `/api/playbooks/:name/export` | One playbook as a JSON document |
| POST | `/api/playbooks/import` | Save an export: { playbook, name?, overwrite? } |
| DELETE | `/api/playbooks/:name` | Delete a playbook |

Every endpoint takes the API key as a bearer token, like the rest of the REST API.

## SDK

`@oya-ai/browser` is TypeScript with no runtime dependencies, shipped as ESM, CJS and types. Element IDs come from `analyze()` and are only valid until the page changes, after a navigation or a click that redraws, analyze again.

```
const page = await browser.analyze();      // markdown + numbered elements ({ format: 'toon' } for TOON)
const els  = await browser.elements();     // just the visible ones

await browser.click(13);
await browser.type(9, "hello");
await browser.pressKey("Enter");
await browser.waitFor("[data-testid=results]");
await browser.scroll("bottom");

const png = await browser.screenshot();    // base64
const answer = await browser.ask("find the pricing page");

await browser.solveCaptcha();              // { solved, method }
await browser.completeMfa();               // { completed, method, liveViewUrl }
await browser.close();
```

### Bring your own tools

`browser.cdpUrl` is our gateway URL, not the vendor's, point Playwright, Puppeteer, Stagehand or browser-use at it and you get routing, profile capture and session recording without any of them knowing this exists.

```
const browser = await oya.browser.start();
const pw = await chromium.connectOverCDP(browser.cdpUrl);
```

The gateway also answers `/json/version` and `/json/list`, which is what lets those clients treat it as an ordinary browser.

## CLI

```
oya login                       Save an API key for this machine
oya init                        Model, browser provider, solver, desktop sign-in
oya start [--persona auto]      Start a browser and print its id
oya goto <url>                  Navigate (defaults to the newest browser)
oya ask "<prompt>"              Drive it in plain language
oya ls                          What is running
oya rm <id> | --all             Stop browsers
oya personas [new|rm <id>]      Identities and their concurrency
oya open                        Watch a browser work
oya playbooks                   Saved playbooks
oya playbooks export <name>     One playbook as JSON [--out <file>]
oya playbooks import <file>     Save an export here [--name <n>] [--replace]
oya config [key=value ...]      This key's settings
oya usage                       What this key has spent
oya stealth-test [--live]       Score this deployment against bot detectors
```

Flags and `OYA_API_KEY` / `OYA_BASE_URL` beat the saved file, so CI never needs `oya login`. The key is stored at `~/.oya/config.json`, mode 600.

## MCP Setup

One endpoint for everything you have. It starts browsers itself, so there is no id to look up first:

```
https://oyabrowser.com/mcp/pool
```

Seventeen tools: fourteen that act on a page, round-robined across your browsers, plus `start_browser`, `stop_browser` and `pool_status`. Tab tools and `read_elements` are not on this endpoint; to use those, point at one browser instead, `/mcp/{BROWSER_ID}`, with the id from the [dashboard](https://oyabrowser.com/dashboard), which serves nineteen.

### Cursor

Add to `.cursor/mcp.json` in your project:

```
{
  "mcpServers": {
    "oya-browser": {
      "url": "https://oyabrowser.com/mcp/pool",
      "transport": "streamable-http",
      "headers": {
        "Authorization": "Bearer YOUR_API_KEY"
      }
    }
  }
}
```

### Claude Desktop

Add to Claude Desktop's MCP config (Settings → Developer → Edit Config):

```
{
  "mcpServers": {
    "oya-browser": {
      "url": "https://oyabrowser.com/mcp/pool",
      "transport": "streamable-http",
      "headers": {
        "Authorization": "Bearer YOUR_API_KEY"
      }
    }
  }
}
```

### Claude Code

Same config in `.claude/mcp.json`, or install the plugin, which brings the MCP server and a skill that teaches the workflow:

```
claude plugin marketplace add OyadotAI/oya-browser
claude plugin install oya-browser@oya

# any agent that reads skills, without the plugin:
npx skills add OyadotAI/oya-browser
```

## analyze_page

Analyzes the current page. Returns the full page with every interactive element numbered, as markdown (the default), TOON or JSONL. The default is the one picked in the Oya Browser's settings, unless the server's `OYA_PAGE_FORMAT` pins one.

```
analyze_page()                  // markdown
analyze_page({ format: 'toon' }) // TOON: fewer tokens
analyze_page({ format: 'jsonl' }) // one JSON object per line
```

Returns:

- Page metadata, URL, title, viewport size, scroll position
- Full page content with element tags like `[#5 button "Submit"]` in markdown, or one `blocks[N]{id,region,kind,text,target,state}` row per block in TOON, or one JSON object per block in JSONL
- Element index, all elements listed with IDs, types, labels, visibility flags

Always call `analyze_page` before using `click` or `type`. Element IDs only exist after analysis and reset on every call.

## navigate

Navigate the browser to a URL.

```
navigate({ url: "https://example.com" })
```

After navigating, call `analyze_page` again, old element IDs are invalid on the new page.

## click

Click an interactive element by its ID number from `analyze_page`.

```
click({ element_id: 13 })
```

The element was tagged with `data-ac-id="13"` during analysis, so the click resolves via a single `querySelector`.

## type

Type text into an input element. Clears existing content first, then types character by character with realistic key events.

```
type({ element_id: 9, text: "hello world" })
```

## press_key

Press a keyboard key. Useful for submitting forms (Enter), dismissing dialogs (Escape), or navigating (Tab, arrows).

```
press_key({ key: "Enter" })
```

Supported keys: `Enter`, `Escape`, `Tab`, `Backspace`, `ArrowDown`, `ArrowUp`, or any character.

## screenshot

Capture the visible tab as a base64 PNG image.

```
screenshot()
```

## scroll

Scroll the page up or down.

```
scroll({ direction: "down", amount: 500 })
```

| Param | Type | Description |
| --- | --- | --- |
| `direction` | `"up"` \| `"down"` | Scroll direction |
| `amount` | number (optional) | Pixels to scroll, default 500 |

## Tab Management

### list_tabs

List all open tabs with ID, title, URL, and which is active.

```
list_tabs()
```

### open_tab

Open a new tab, optionally at a URL.

```
open_tab({ url: "https://gmail.com" })
```

### switch_tab

Switch to a tab by ID (from `list_tabs`).

```
switch_tab({ tab_id: 2 })
```

### close_tab

Close a tab. Closes the active tab if no ID specified.

```
close_tab({ tab_id: 3 })
```

## wait

Wait for an element matching a CSS selector to appear on the page.

```
wait({ selector: ".results", timeout: 10000 })
```

## Personas

A **persona** is one identity: a fingerprint, a cookie jar and a proxy, bound together and stable for its life. One persona is one device.

There are two ways to get caught, and they are mirror images of each other:

| Shape | Signal |
| --- | --- |
| One account seen from many device fingerprints | Textbook bot farm |
| One device fingerprint across many accounts, or 1,000 concurrent sessions | Device farm |

Binding the fingerprint to your API key avoids the first and walks straight into the second. Binding it to each browser avoids the second and walks into the first. So the binding sits at the level that actually corresponds to a device:

```
persona = fingerprint + cookie jar + proxy       # one identity, one device
API key = a group of personas                    # your fleet
```

A persona's fingerprint is derived from a stored seed, so it is byte-identical across restarts, a returning session looks like a returning device, not a new one.

```
const p = await oya.personas.create({ name: "acme-ops" });
const browser = await oya.browser.start({ persona: p.id });

await oya.personas.list();     // includes activeBrowsers and maxConcurrent
await oya.personas.remove(p.id);
```

Every API key has a **default** persona whose seed reproduces the fingerprint that key had before personas existed. If you run a single account, nothing changed for you.

### Rotation and concurrency

Rotation means picking a *different* persona, never giving one persona a new fingerprint. `persona: 'auto'` selects the least recently used persona that is still under its concurrency cap.

Concurrency is capped per persona, because one laptop cannot be in a thousand places at once. Named personas default to 2 (a phone and a laptop is plausible); the default persona is uncapped so an existing fleet does not break on upgrade. Past the cap you get a clear 429 rather than a silent breach, and `activeBrowsers` is visible in the dashboard and as a Prometheus metric.

## CAPTCHA

```
await browser.solveCaptcha();                    // explicit
oya.browser.start({ captcha: 'auto' });          // solve as they appear
```

Detects reCAPTCHA v2/v3, hCaptcha and Turnstile. Providers that solve natively, Anchor, Browserbase, Steel, Browser Use, are left to do it rather than paying twice and racing their attempt. Everything else goes to your configured solver (CapSolver or 2Captcha).

Returns `{ solved, method: 'provider' | 'solver' | 'none' }`. A failure returns `solved: false`: a silent no-op that leaves an agent stuck is worse than a clear answer.

Automated solving conflicts with some sites' terms of service. Sessions that used it are recorded in the audit trail so you can see which.

## MFA

```
await oya.personas.setMfa(id, { type: 'totp', secret: 'JBSWY3DPEHPK3PXP' });
await oya.personas.setMfa(id, { type: 'email', url: 'https://mail.example/api/latest' });

const r = await browser.completeMfa();
if (!r.completed) open(r.liveViewUrl);   // finish it by hand
```

TOTP is generated locally (RFC 6238). Email and SMS one-time codes are polled from a relay endpoint you supply, within a bounded window, because the code does not exist yet when the prompt appears. When nothing automated can answer, `liveViewUrl` is where a person finishes; that is also the only workable answer for push-approval MFA.

TOTP seeds are credential material of the same weight as a password: sealed at rest with AES-256-GCM, audited on use, and never returned by the API. The relay URL is checked against private and link-local ranges when you store it *and* on every poll, because a public name says nothing about where it resolves later.

## Anonymity

Create and manage browser profiles with unique fingerprints, proxy routing, and isolated cookie stores. Each profile is a complete identity, different canvas hash, WebGL renderer, navigator properties, and session storage. Switch identities with a single MCP call.

Every browser runs as a persona: a fingerprint, cookie jar and proxy bound together and stable for its life. Rotation means choosing a different persona, never re-rolling one.

### Fingerprint Spoofing

Each profile generates a coherent set of browser fingerprints that are internally consistent per platform. A Win32 profile gets Windows GPU strings, Windows fonts, and matching screen resolutions.

- **Canvas**: deterministic pixel noise on `toDataURL` and `toBlob`
- **WebGL**: spoofed vendor/renderer strings from real GPU database
- **AudioContext**: noise on `OfflineAudioContext.startRendering`
- **ClientRects**: sub-pixel noise on `getBoundingClientRect` (bypassed internally for click accuracy)
- **Navigator**: platform, hardwareConcurrency, deviceMemory, languages, vendor
- **Screen**: width, height, colorDepth, devicePixelRatio
- **WebRTC**: ICE candidates stripped to prevent local IP leak
- **Fonts**: platform-consistent font sets

### Proxy Support

A persona can take an HTTP, HTTPS or SOCKS5 proxy, given as one url. Chromium cannot authenticate to a SOCKS5 proxy, so a proxy that needs a username and password must be HTTP or HTTPS. The proxy is applied at the session level, so all traffic routes through it, including DNS for SOCKS5. Timezone and locale are matched to the proxy's location over CDP, and a mismatch is reported rather than silently shipped.

```
const proxy = await oya.proxies.create({
  url: "http://user:pass@1.2.3.4:8080",
  geo: "us",
  kind: "datacenter",
});

const persona = await oya.personas.create({
  name: "us-desktop",
  prefs: { platform: "Win32", timezone: "America/New_York" },
});
await oya.personas.pinProxy(persona.id, proxy.id);
```

### Anti-Detection Stealth

Always active, no configuration needed. The stealth layer removes automation indicators that anti-bot systems check for:

- `navigator.webdriver` removed
- Electron globals (`window.process`, `window.require`) deleted
- `window.chrome` fixed to match real Chrome (app, runtime, csi, loadTimes)
- `navigator.plugins` populated with PDF viewers
- `navigator.permissions.query` patched
- Sec-CH-UA headers rewritten to hide Electron
- Google telemetry domains blocked at the network level

## Choosing a persona

A persona is made through the API and chosen when a browser starts. There is no tool that switches one mid-session: the device, cookie jar and proxy are bound together for the persona's life, so changing them would make the browser a different machine halfway through a run. To vary the device, clone the persona.

```
const personas = await oya.personas.list();
const browser = await oya.browser.start({ persona: personas[0].id });

// A new device, fixed from here on:
const fresh = await oya.personas.create({ prefs: { platform: "MacIntel" } });

// The same device, a second cookie jar:
const twin = await oya.personas.clone(fresh.id);
```

The console calls this workspace **Profiles**; the API and these docs call it a persona. Same thing.

## Self-hosting

Run the whole stack in your own network, for HIPAA and SOC 2 workloads or anywhere data may not leave. It needs git, Docker and Node 20 or later.

```
curl -fsSL https://raw.githubusercontent.com/OyadotAI/oya-browser/main/install.sh | sh
```

A wizard asks six questions, writes the config, starts the stack, waits until it is ready, and prints an API key. Add `--dry-run` after `sh -s --` to see the plan without writing anything.

| Choice | Options |
| --- | --- |
| Control plane | Docker on one machine; Amazon ECS, Kubernetes or Google Cloud for production |
| Database | SQLite (default, one replica), Postgres (many replicas) or JSON files |
| Browsers | Docker workers, one container or pod per session, Oya Cloud, or your own Chrome over CDP |
| LLM | Anthropic, OpenAI, Gemini, any OpenAI-compatible endpoint, or a local model (Ollama, vLLM) |

### Point your code at it

```
oya login --url http://localhost:3100 --key <the key it printed>
oya ls                             # the browsers that enrolled
oya goto https://example.com
```

In code, set `OYA_BASE_URL` next to `OYA_API_KEY`; everything else in these docs works unchanged.

### Settings that matter

| Variable | What it does |
| --- | --- |
| `OYA_PROFILE_SECRET` | Encrypts cookies, passwords and TOTP seeds at rest. Generated into the data volume when unset, so keep that volume. |
| `API_KEYS` | Comma-separated tenant keys. The control plane needs nothing else. |
| `OYA_STORAGE` | sqlite, postgres or file: where every table lives. |
| `DATABASE_URL` | The Postgres connection string, with OYA_STORAGE=postgres. |
| `OPENAI_API_KEY` | The default model for agents. Each API key can set its own. |

Keep the `oya-data` volume, or set `OYA_PROFILE_SECRET` yourself. Lose the secret and every stored login becomes unreadable.

Databases, cloud browser runtimes, upgrades and every setting are in the [full self-hosting guide](https://github.com/OyadotAI/oya-browser/blob/main/docs/self-hosting.md).

## Control Plane Architecture

Raw browser runners like **Browserbase**, **Steel**, **Anchor**, and **Browser Use** are execution targets: they spin up headless Chromium instances inside isolated containers or VMs.

**Oya is the Control Plane.** It sits above the execution targets and manages the state, identity, authentication, challenge resolution, and orchestration that production agent fleets require:

- **Universal Router:** Exposes unified CDP (`/connect`), MCP, and REST interfaces. Route requests across providers with priority order and automatic failover.
- **Deterministic Personas:** Mathematically seeded device profiles. Canvas, WebGL, audio, and client rects stay byte-identical across restarts, bound to a dedicated cookie jar and proxy.
- **Sign-In-Once Desktop Pairing:** Transfer authenticated sessions from real desktop Chrome (with WebAuthn, passkeys, and Google SSO) to remote personas via single-use encrypted codes.
- **Two-Tier Challenges:** Automatic native delegation to CAPTCHA solvers, automated TOTP and SMS/email relays, and sub-second interactive live stream handoffs for human intervention.
- **Fleet Governance:** High-density console for 1,000+ browsers, real-time command activity logs, Prometheus metrics (`/metrics`), and hourly spend attribution per tenant key.

By decoupling the *control plane* from the *execution engine*, your agent codebase never has to know or care which cloud provider or bare-metal machine runs a session.

## Why Oya: The 10x Advantage

Directly coding agents to single-vendor browser runners creates brittle architectures. Here is why an orchestrating control plane is 10x better than relying on raw point solutions:

| Dimension | Raw Runners (Browserbase, Steel, Anchor, Browser Use) | Oya Control Plane |
| --- | --- | --- |
| Architecture | Single-vendor lock-in. Outages or regional IP blocks halt all agents. | Unified control plane. Dynamic routing across multiple providers with automatic failover. |
| Device Identity | Ephemeral dumb sessions or random fingerprints that trigger bot-farm heuristics. | Deterministic Personas. Cryptographically seeded hardware fingerprints byte-identical across restarts. |
| Authentication | Fragile scripted headless logins that fail on Google SSO, passkeys, and Cloudflare. | Sign-In-Once Desktop Pairing. Log in once on desktop; cookies sync securely to cloud personas. |
| Challenges & 2FA | Fails or hangs on push approvals or unexpected verification prompts. | Two-Tier Engine + Live Takeover. Automated TOTP/SMS relay + sub-second interactive takeover. |
| Observability | Opaque session IDs, black-box execution, post-mortem static videos. | 1,000+ browser console, real-time activity log, Prometheus metrics, hourly spend attribution. |
| Protocol Freedom | Proprietary SDKs and bespoke API wrappers. | Universal Gateway: Native CDP (/connect), MCP streamable HTTP, TS SDK, and CLI. |
| Stealth Testing | Unverifiable marketing claims of "undetectable" scrapers. | Open benchmark suite (oya stealth-test --live) scored against CreepJS and Bot.Sannysoft. |

## Multi-Provider Routing & Failover

Configure providers in the dashboard under **Control → Providers** or via the API. Each provider has a unique route name, vendor type, priority (0 goes first), and session capacity.

```
// Point any CDP client at the Oya Control Plane gateway:
const browser = await chromium.connectOverCDP(
  "wss://oyabrowser.com/connect?token=YOUR_OYA_KEY"
);

// Oya selects the highest-priority available provider.
// If Steel errors or hits rate limits, Oya instantly fails over to Browserbase or Oya Cloud.
```

When a connection attempt to an upstream vendor fails, the control plane immediately catches the error, puts the failing route into a cooldown period, and dispatches the connection to the next healthy provider in priority order. Your client application never observes a disconnect.

## Stealth & Live Benchmarks

Rather than making unsubstantiated marketing claims about detection resistance, Oya includes an open testing suite that benchmarks browser evasion against real detectors:

```
oya stealth-test            # Score local probe suite
oya stealth-test --live     # Benchmark live against Bot.Sannysoft and CreepJS
```

The suite tests canvas noise, WebGL renderer and vendor strings, AudioContext noise, client rects, plugins, `navigator.webdriver`, `userAgentData`, media devices, and `Function.prototype.toString` masking.

Oya deliberately does **not** double-layer custom stealth over providers that already ship tuned anti-bot stealth (Anchor, Browserbase, Steel, Browser Use). Double-masking causes internal contradictions that anti-bot heuristics detect. On those providers, Oya manages the persona identity, cookie jar, residential proxy, and concurrency limits.

## Dashboard

**Browsers, commands, and CDP sessions**
A browser is a running desktop or cloud instance. REST commands, including curl requests to `/api/browsers/:id/command`, appear in that browser’s Activity history and count toward Usage. A CDP session is a persistent client connection through `/connect`, typically from Playwright or Puppeteer. Find these under Control → CDP sessions.

The [dashboard](https://oyabrowser.com/dashboard) at `/dashboard` is the control panel. It shows your connected browsers and lets you interact with them.

Built to hold a thousand browsers and let you act on any one of them:

- **Browsers**: a health strip (every number is a filter) over a dense table: health, persona, provider, current page, commands · errors, seen, uptime. Select a row to open the panel: URL bar, a bounded *interactive* live view, screenshot, elements, stats, and the activity log, what that browser has been doing, newest first.
- **Personas**: one identity each. Create with a chosen device and a live fingerprint preview; edit name, cap, proxy pin and MFA; the device itself is locked, with *Clone* for when you want a different one.
- **Control**: health, gateway sessions, providers and routing, per-key usage, the audit trail, recordings.

### Adding a provider

Open **Control → Providers → Add provider**. Give the route a unique name, choose a vendor, and enter its API key. A credential already saved in Settings can be reused. For your own Chrome, supply its CDP WebSocket URL instead.

Set the session capacity and routing priority (0 goes first). Providers and your routing strategy are saved for your Oya key across restarts; credentials and connection URLs are encrypted. Saving a provider does not launch a browser or verify its credentials. Its first connection does that. End active sessions before removing a route.

These routes serve new CDP connections to `/connect?token=YOUR_OYA_KEY`. The **Start browser** action uses your provider selection in **Settings → Browsers**. Attaching with `?browser=ID` connects to that existing browser.

### Driving a browser from the live view

Choose **Stream** in a browser panel, or **Open live stream in a tab** from its menu, to open an interactive viewer in a separate tab. Your dashboard key authorizes the viewer. The `/api/live/:id` endpoint is the raw event stream for integrations.

Click to control. Clicks land at the page pixel under the cursor, a drag is a drag, the wheel scrolls, typing is batched into `keyboard_type` and the named keys go as `press_key`. `Esc` hands the keyboard back. What was typed is never written to the activity log, it records *2 chars*, not the text.

### Connect to a browser that is already running

Right-click any row (or press **Connect** in the panel) for code that targets that exact browser: SDK, CLI, an MCP config, curl, and for CDP-backed browsers a Playwright `connectOverCDP` URL. Snippets are written for this deployment and your key; the key is masked until you ask, and copy always copies the real one.

```
// Attach through the gateway to one browser in the fleet. Closing your
// client leaves the browser running.
const browser = await chromium.connectOverCDP(
  "wss://<host>/connect?token=<api-key>&browser=<browser-id>",
);
```

Only CDP-backed browsers (Browserbase, Steel, Anchor, your own Chrome) have an endpoint to attach to; an Oya client is driven over its own socket, so use the SDK, CLI or MCP for those.

### Stop means stop

One button, one endpoint (`POST /browsers/:id/stop`). A cloud browser's sandbox is destroyed so billing ends; a CDP browser is handed back to its provider; a desktop browser disconnects. The confirm says which. Bulk stop takes `{ids: [...]}` or `{all: true}`.

### Keyboard

| Key | Does |
| --- | --- |
| ⌘/Ctrl 1 · 2 · 3 | Browsers · Personas · Control |
| n | Start a browser |
| / | Filter the fleet |
| ↑ ↓ or j k | Move the selection |
| x | Stop the selected browser(s) |
| l · r · s | URL bar · reload · screenshot |
| Esc | Close the panel, or release the keyboard from the live view |
| ? | The full list |

You can sign in with an account, or by pasting an API key: a self-hosted deployment with `API_KEYS` and no database has no accounts, and still needs its own UI.

### Onboarding

A key that has not been set up gets a two-step page. Everything it asks is stored against that key, nothing lands in an environment variable, and nothing is inherited from an account.

1. **Desktop**: one click opens the desktop browser signed in to the project
2. **AI model**: Claude, OpenAI or Gemini and your key, which Ask in the desktop needs. Ask also asks for it inline if it is missing

Where browsers run, CAPTCHA solving and the model itself are all in Settings, and `oya init` sets them up in a terminal.

### Dev Panel (Desktop App)

The desktop app's dev panel (`{}` button in the toolbar) has four tabs:

- **Chat**: natural language browser control with formatted responses and tool badges
- **Actions**: quick-fire buttons and input fields for every command: analyze, screenshot, navigate, click by element #, type, press keys, hover, scroll, wait, tab management
- **Network**: live WebSocket traffic with IN/OUT badges, expandable payloads, filter by direction or type (All, In, Out, Commands, Results)
- **Source**: view the page as AI sees it: toggle between Markdown (analyzePage output) and HTML source, refresh on demand

### Live View

Select a browser on the Browsers tab to watch it work. Frames stream as JPEG over SSE at ~2fps. `browser.liveViewUrl()` is the console deep link for a person to open; `await browser.liveStreamUrl()` gives you the same frames to embed, with a single-use ticket that expires in 60 seconds, EventSource cannot set headers, and a URL that ends up in browser history should not be a permanent credential.

### Settings

The gear icon next to the API key bar. Everything here belongs to that key: model provider and credential, default model, browser provider and its credential, CAPTCHA solver, and the one-click desktop sign-in.

Credentials are sealed at rest with AES-256-GCM and always read back masked. Saving the masked placeholder never overwrites the real value.

A key that has set nothing falls back to the deployment-wide defaults. Changing *those* affects every key that has not set its own, so it needs `OYA_OPERATOR_TOKEN` via `POST /config/host` rather than any API key.

## REST API

All endpoints require `Authorization: Bearer YOUR_API_KEY` header (except health and register). Interactive API testing available at [/swagger](https://oyabrowser.com/swagger).

| Method | Endpoint | Description |
| --- | --- | --- |
| `GET` | `/health` | Server status + browser count |
| `POST` | `/register-key` | Register a new API key (`{ "key": "..." }`) |
| `GET` | `/browsers` | List your connected browsers |
| `POST` | `/browsers/start` | Start one (`{ "persona": "auto" }`), provider comes from your key; an endpoint another browser holds answers 409 `endpoint_in_use` |
| `GET` | `/browsers/:id` | One browser with counters, health and its recent activity |
| `POST` | `/browsers/:id/stop` | Stop it, destroys a cloud sandbox, releases a CDP session |
| `POST` | `/browsers/stop` | Bulk: `{ "ids": [...] }` or `{ "all": true }` |
| `GET` | `/fleet` | Totals by health, provider and persona; usage and limits |
| `POST` | `/browsers/:id/command` | Send command (`{ "action": "...", "params": {} }`) |
| `POST` | `/browsers/:id/chat` | Chat (`{ "messages": [...] }`) |
| `GET` | `/live/:id?ticket=...` | SSE live view frame stream (single-use ticket) |
| `GET/POST` | `/mcp/:id` | MCP Streamable HTTP endpoint |
| `GET/POST` | `/personas` | List or create personas |
| `DELETE` | `/personas/:id` | Delete a persona (409 while in use) |
| `PUT` | `/personas/:id` | Rename, set the cap or the proxy hint, never the device |
| `POST` | `/personas/:id/clone` | A new persona of the same kind of device |
| `POST` | `/personas/preview` | The fingerprint a set of choices would produce |
| `GET` | `/personas/options` | Platforms and their coherent timezones and locales |
| `PUT` | `/personas/:id/mfa` | Store a second factor |
| `POST` | `/browsers/:id/captcha` | Detect and clear a CAPTCHA |
| `POST` | `/browsers/:id/mfa` | Answer an MFA prompt |
| `GET` | `/usage` | This key's usage, bucketed by hour |
| `GET` | `/audit` | This key's audit history |
| `GET` | `/config` | This key's settings, credentials masked |
| `POST` | `/config` | Update this key's settings |

### Command API Reference

Send commands via `POST /browsers/:id/command`. Each action uses only specific params, the rest are ignored.

### Navigation Actions

| Action | Params | Description |
| --- | --- | --- |
| `navigate` | `url` (required) | Navigate to a URL |
| `open_tab` | `url` (optional) | Open a new tab |
| `switch_tab` | `tab_id` (required) | Activate a tab by ID |
| `close_tab` | `tab_id` (optional, defaults to active) | Close a tab |
| `list_tabs` | *none* | List all open tabs |

### Page Analysis Actions

| Action | Params | Description |
| --- | --- | --- |
| `analyze` | `format?` | Full page + numbered elements, as markdown (default), toon or jsonl |
| `read_page` | `selector` (optional), `limit` (default 50) | Lightweight element listing |
| `screenshot` | *none* | Capture page as PNG |

### Interaction Actions

| Action | Params | Description |
| --- | --- | --- |
| `click` | `selector` (e.g. `[data-ac-id="3"]`) | Click an element |
| `type` | `selector` + `text` | Type into an input |
| `press_key` | `key` (e.g. Enter, Tab, Escape) | Press a keyboard key |
| `scroll` | `direction` (up/down), `amount` (px, default 500) | Scroll the page |
| `wait` | `selector`, `timeout` (ms, default 10000) | Wait for element to appear |

### Examples

```
// Navigate to a page
{ "action": "navigate", "params": { "url": "https://google.com" } }

// Analyze current page (no params needed)
{ "action": "analyze" }

// Click element #3 from analyze results
{ "action": "click", "params": { "selector": "[data-ac-id=\"3\"]" } }

// Type into element #9
{ "action": "type", "params": { "selector": "[data-ac-id=\"9\"]", "text": "hello world" } }

// Press Enter
{ "action": "press_key", "params": { "key": "Enter" } }

// Scroll down
{ "action": "scroll", "params": { "direction": "down", "amount": 500 } }

// Screenshot (no params needed)
{ "action": "screenshot" }

// List all tabs
{ "action": "list_tabs" }

// Open new tab
{ "action": "open_tab", "params": { "url": "https://gmail.com" } }

// Switch to tab
{ "action": "switch_tab", "params": { "tab_id": 2 } }

// Close tab (omit tab_id to close active tab)
{ "action": "close_tab", "params": { "tab_id": 3 } }

// Wait for element
{ "action": "wait", "params": { "selector": ".results", "timeout": 10000 } }

// Read page elements (lightweight)
{ "action": "read_page", "params": { "limit": 20 } }
```

### Typical Workflow

```
1. navigate → go to the page
2. analyze  → understand the page, get element IDs
3. click / type / press_key / scroll → interact
4. analyze  → re-analyze after page changes (old IDs are invalid)
5. repeat until task is done
```

## WebSocket Protocol

Browsers connect via WebSocket at `wss://oyabrowser.com/ws`.

### Auth

First message from browser:

```
{ "type": "auth", "api_key": "...", "browser_id": "...", "browser_name": "..." }
```

Server responds:

```
{ "type": "auth_ok", "browser_id": "..." }
```

### Commands

Server → Browser:

```
{ "type": "cmd", "id": "uuid", "action": "analyze", "params": {} }
```

Browser → Server:

```
{ "type": "cmd_result", "id": "uuid", "ok": true, "data": { ... } }
```

### Ping/Pong

Both sides send `{ "type": "ping" }` and respond with `{ "type": "pong" }` every 15-20 seconds.

### Live Stream

Server → Browser: `{ "type": "stream_start", "fps": 2 }`

Browser → Server: `{ "type": "frame", "data": "data:image/jpeg;base64,..." }`

Server → Browser: `{ "type": "stream_stop" }`
