@oya-ai/browser 1.0.103 → 1.0.105

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,49 +1,54 @@
1
1
  # @oya-ai/browser
2
2
 
3
3
  <p align="center">
4
- <strong>Thousands of browsers behind one API: personas, residential proxies, measured stealth, CAPTCHA, and MFA.</strong>
4
+ <strong>A real browser your agents drive from the inside. Record a run once, replay it with no model in the loop.</strong>
5
5
  </p>
6
6
 
7
7
  <p align="center">
8
8
  <a href="https://www.npmjs.com/package/@oya-ai/browser"><img src="https://img.shields.io/npm/v/@oya-ai/browser?color=39ed35&label=@oya-ai/browser&logo=npm" alt="NPM Version"></a>
9
9
  <a href="https://github.com/OyadotAI/oya-browser/blob/main/packages/sdk/LICENSE"><img src="https://img.shields.io/badge/license-MIT-blue.svg?color=39ed35" alt="License: MIT"></a>
10
10
  <a href="https://www.typescriptlang.org"><img src="https://img.shields.io/badge/TypeScript-Ready-3178C6.svg?logo=typescript&logoColor=white" alt="TypeScript"></a>
11
- <a href="https://nodejs.org"><img src="https://img.shields.io/badge/node-%3E%3D18.0.0-brightgreen.svg" alt="Node Version"></a>
11
+ <a href="https://nodejs.org"><img src="https://img.shields.io/badge/node-%3E%3D20.0.0-brightgreen.svg" alt="Node Version"></a>
12
12
  <a href="https://bundlephobia.com/package/@oya-ai/browser"><img src="https://img.shields.io/bundlephobia/minzip/@oya-ai/browser?color=39ed35" alt="Bundle Size"></a>
13
13
  </p>
14
14
 
15
15
  ---
16
16
 
17
- Orchestrate browser instances running on **Oya Cloud sandboxes**, **Browserbase**, **Steel**, **Anchor**, **Browser Use**, or your own **private Chrome fleet**.
18
-
19
- Oya does for browser vendors what OpenRouter does for LLM providers. The vendor behind a browser is a setting on your API key, or one `provider` parameter, so switching vendors never means rewriting your agent.
17
+ ## From nothing to an answer
20
18
 
21
19
  ```bash
22
20
  npm install @oya-ai/browser
23
21
  ```
24
22
 
25
- Get an API key at [browser.getoya.ai](https://browser.getoya.ai) or self-host your control plane, and set `OYA_API_KEY`.
23
+ ```js
24
+ import { Oya } from '@oya-ai/browser';
26
25
 
27
- ---
26
+ const browser = await new Oya().browser.start();
27
+ await browser.goto('https://news.ycombinator.com');
28
+ console.log(await browser.ask('What are the top 3 stories?'));
29
+ ```
28
30
 
29
- ## Quickstart
31
+ Two things to have first: an API key from [oyabrowser.com](https://oyabrowser.com)
32
+ (or your own deployment) in `OYA_API_KEY`, and a browser to drive.
30
33
 
31
- Requires Node.js 18+; examples use ES modules. Set `OYA_API_KEY` in your environment. `OYA_BASE_URL` optionally points to a self-hosted control plane.
34
+ **The fastest browser to have is one you already have.** Open the
35
+ [Oya desktop browser](https://oyabrowser.com) and sign in with the same key: `start()`
36
+ hands over the browser that is already connected when no provider is configured, so the
37
+ five lines above work with nothing else set up. `stop()` leaves a browser you borrowed
38
+ alone, because you did not start it.
32
39
 
33
- ```js
34
- import { Oya } from "@oya-ai/browser";
40
+ When you would rather it start one for you, run `npx @oya-ai/cli init` and pick where
41
+ browsers run: Oya Cloud, your own Docker, Browserbase, Steel, Anchor, Browser Use, or a
42
+ Chrome of your own over CDP. Your code does not change.
35
43
 
36
- const oya = new Oya();
37
- const browser = await oya.browser.start({ captcha: "auto" });
38
- try {
39
- await browser.goto("https://example.com");
40
- console.log(await browser.ask("What is the main heading on this page?"));
41
- } finally {
42
- await browser.stop();
43
- }
44
- ```
44
+ **Where the key comes from**, in order: `new Oya({ apiKey })`, then `OYA_API_KEY`, then the
45
+ file `oya login` wrote (`~/.oya/config.json`, or `OYA_CONFIG_HOME`), which is read on Node
46
+ 22.3 and newer. `baseUrl` resolves the same way. A CI job that sets neither fails loudly
47
+ rather than borrowing whatever is on the machine.
45
48
 
46
- On Node.js 24+, `await using browser = await oya.browser.start()` also stops the browser when its scope exits, including on error.
49
+ Node.js 20 or newer; the examples are ES modules. `OYA_BASE_URL` points at a self-hosted
50
+ control plane. On Node.js 24+, `await using browser = await oya.browser.start()` stops it
51
+ when the scope exits, errors included.
47
52
 
48
53
  ## Portal automation: record once, replay with new inputs
49
54
 
@@ -52,8 +57,8 @@ This example adapts the portal-automation project's workflow: reuse a persona, a
52
57
  Save as `portal.mjs` and run `node portal.mjs` after setting `OYA_API_KEY`, `PORTAL_URL`, `PORTAL_USERNAME`, and `PORTAL_PASSWORD`. Set `OYA_BROWSER_ID` only to reuse an already running browser.
53
58
 
54
59
  ```js
55
- import { createInterface } from "node:readline/promises";
56
- import { Oya } from "@oya-ai/browser";
60
+ import { createInterface } from 'node:readline/promises';
61
+ import { Oya } from '@oya-ai/browser';
57
62
 
58
63
  function requiredEnv(name) {
59
64
  const value = process.env[name];
@@ -61,79 +66,76 @@ function requiredEnv(name) {
61
66
  return value;
62
67
  }
63
68
 
64
- const oya = new Oya({ apiKey: requiredEnv("OYA_API_KEY") });
65
- const portalUrl = requiredEnv("PORTAL_URL");
66
- const playbookName = "portal-request-review";
69
+ const oya = new Oya({ apiKey: requiredEnv('OYA_API_KEY') });
70
+ const portalUrl = requiredEnv('PORTAL_URL');
71
+ const playbookName = 'portal-request-review';
67
72
  const secrets = {
68
- username: requiredEnv("PORTAL_USERNAME"),
69
- password: requiredEnv("PORTAL_PASSWORD"),
73
+ username: requiredEnv('PORTAL_USERNAME'),
74
+ password: requiredEnv('PORTAL_PASSWORD'),
70
75
  };
71
76
  // Fictional test inputs. data is visible to the agent.
72
77
  const data = {
73
- customerName: "Alex Example",
74
- requestId: "DEMO-0001",
75
- requestedDate: "2030-01-15",
78
+ customerName: 'Alex Example',
79
+ requestId: 'DEMO-0001',
80
+ requestedDate: '2030-01-15',
76
81
  };
77
82
  const task = [
78
- "If not logged in, log in with {{username}} and {{password}}.",
79
- "Open New Request and enter {{requestId}} as the reference.",
80
- "Fill first name {{customerName|first}} and last name {{customerName|last}}.",
81
- "Set the requested date to {{requestedDate|date:MM/DD/YYYY}}.",
82
- "If a field is already correct, do not type its value again.",
83
- "If an action times out, inspect the page before retrying it.",
84
- "If information is missing, ask the person instead of guessing.",
85
- "Stop on the review page. Do not submit the request.",
86
- ].join("\n");
83
+ 'If not logged in, log in with {{username}} and {{password}}.',
84
+ 'Open New Request and enter {{requestId}} as the reference.',
85
+ 'Fill first name {{customerName|first}} and last name {{customerName|last}}.',
86
+ 'Set the requested date to {{requestedDate|date:MM/DD/YYYY}}.',
87
+ 'If a field is already correct, do not type its value again.',
88
+ 'If an action times out, inspect the page before retrying it.',
89
+ 'If information is missing, ask the person instead of guessing.',
90
+ 'Stop on the review page. Do not submit the request.',
91
+ ].join('\n');
87
92
 
88
93
  const existingId = process.env.OYA_BROWSER_ID;
89
94
  let browser;
90
95
  if (existingId) {
91
96
  browser = await oya.browser.get(existingId);
92
97
  } else {
93
- const persona = (await oya.personas.list())
94
- .find((p) => p.name === "portal-demo")
95
- ?? await oya.personas.create({ name: "portal-demo" });
96
- browser = await oya.browser.start({ persona: persona.id, captcha: "auto" });
98
+ const persona =
99
+ (await oya.personas.list()).find((p) => p.name === 'portal-demo') ??
100
+ (await oya.personas.create({ name: 'portal-demo' }));
101
+ browser = await oya.browser.start({ persona: persona.id, captcha: 'auto' });
97
102
  }
98
103
 
99
104
  try {
100
- console.log("Watch in your Oya dashboard:", browser.liveViewUrl());
105
+ console.log('Watch in your Oya dashboard:', browser.liveViewUrl());
101
106
  await browser.goto(portalUrl);
102
107
  const exists = (await oya.playbooks.list()).some((p) => p.name === playbookName);
103
- const run = await browser.submit(
104
- exists ? { playbook: playbookName } : { prompt: task },
105
- {
106
- // Replay accepts all variables in data; the playbook remembers secret names.
107
- ...(exists ? { data: { ...data, ...secrets } } : { data, secrets }),
108
- onSuccess: () => console.log("Run succeeded."),
109
- onFailure: (error) => console.error("Run failed with status:", error.status),
110
- onHealed: (result) => {
111
- console.log("A repair draft is ready for review:", result.draft);
112
- },
113
- onHumanAttention: async (request) => {
114
- console.log("Attention needed:", request.reason);
115
- console.log(request.message);
116
- console.log("Open:", request.liveViewUrl ?? browser.liveViewUrl());
117
- const terminal = createInterface({ input: process.stdin, output: process.stdout });
118
- try {
119
- const answer = await terminal.question(request.reason === "agent"
120
- ? "Answer the agent: "
121
- : "Handle this in the live view, then press Enter: ");
122
- await request.respond(answer || "done");
123
- } finally {
124
- terminal.close();
125
- }
126
- },
108
+ const run = await browser.submit(exists ? { playbook: playbookName } : { prompt: task }, {
109
+ // Replay accepts all variables in data; the playbook remembers secret names.
110
+ ...(exists ? { data: { ...data, ...secrets } } : { data, secrets }),
111
+ onSuccess: () => console.log('Run succeeded.'),
112
+ onFailure: (error) => console.error('Run failed with status:', error.status),
113
+ onHealed: (result) => {
114
+ console.log('A repair draft is ready for review:', result.draft);
115
+ },
116
+ onHumanAttention: async (request) => {
117
+ console.log('Attention needed:', request.reason);
118
+ console.log(request.message);
119
+ console.log('Open:', request.liveViewUrl ?? browser.liveViewUrl());
120
+ const terminal = createInterface({ input: process.stdin, output: process.stdout });
121
+ try {
122
+ const answer = await terminal.question(
123
+ request.reason === 'agent' ? 'Answer the agent: ' : 'Handle this in the live view, then press Enter: ',
124
+ );
125
+ await request.respond(answer || 'done');
126
+ } finally {
127
+ terminal.close();
128
+ }
127
129
  },
128
- );
130
+ });
129
131
 
130
132
  await run.done; // Rejects on failure; do not save a failed run as a playbook.
131
133
  const info = await run.status();
132
- console.log("Run status:", info.status);
134
+ console.log('Run status:', info.status);
133
135
  if (!exists) {
134
136
  const playbook = await browser.toPlaybook(playbookName);
135
- console.log("Saved:", playbook.name, "Steps:", playbook.steps);
136
- console.log("Variables:", playbook.variables);
137
+ console.log('Saved:', playbook.name, 'Steps:', playbook.steps);
138
+ console.log('Variables:', playbook.variables);
137
139
  // playbook.code contains the flow as an exported Playwright module.
138
140
  }
139
141
  } finally {
@@ -152,22 +154,22 @@ For prompt runs, pass credentials in `secrets`. For replay, pass all variables i
152
154
 
153
155
  ### Files
154
156
 
155
- `file()` puts a file in `data`. The agent attaches it with its upload tool: hand it the element id of whatever you can see — the "Choose file" button, the drop zone, the field itself — and the real `<input type="file">` is found from there, including the hidden ones most upload widgets use.
157
+ `file()` puts a file in `data`. The agent attaches it with its upload tool: hand it the element id of whatever you can see (the "Choose file" button, the drop zone, the field itself) and the real `<input type="file">` is found from there, including the hidden ones most upload widgets use.
156
158
 
157
159
  ```js
158
- import { Oya, file } from "@oya-ai/browser";
160
+ import { Oya, file } from '@oya-ai/browser';
159
161
 
160
- await browser.ask("Attach my resume to the application and submit it", {
161
- data: { name: "Ada Lovelace", resume: await file("./cv.pdf") },
162
+ await browser.ask('Attach my resume to the application and submit it', {
163
+ data: { name: 'Ada Lovelace', resume: await file('./cv.pdf') },
162
164
  });
163
165
  ```
164
166
 
165
- A string argument is a path on disk (Node only); a `Blob`, a `File`, or a `Uint8Array` works anywhere. `name` sets the filename the site sees and `type` overrides the MIME guessed from the extension. The ceiling is 10MB per file, and the bytes travel inline with the run — nothing is stored server-side after it ends.
167
+ A string argument is a path on disk (Node only); a `Blob`, a `File`, or a `Uint8Array` works anywhere. `name` sets the filename the site sees and `type` overrides the MIME guessed from the extension. The ceiling is 10MB per file, and the bytes travel inline with the run. Nothing is stored server-side after it ends.
166
168
 
167
169
  Files work in `data` for `ask()`, `submit()`, and `play()`; `secrets` rejects them, because a file is never typed through a placeholder. A run recorded with `toPlaybook()` keeps the upload as a variable, so the replay takes a different file:
168
170
 
169
171
  ```js
170
- await browser.play("job-application", { name: "Ada Lovelace", resume: await file("./other.pdf") });
172
+ await browser.play('job-application', { name: 'Ada Lovelace', resume: await file('./other.pdf') });
171
173
  ```
172
174
 
173
175
  The generated Playwright module calls `setInputFiles`, where the same variable is a plain path rather than a `file()` value.
@@ -179,8 +181,7 @@ Replay normally runs recorded steps without an LLM. With `autoHeal: true` (the d
179
181
  The following continues with an active `browser` and the inputs above. Replaying a draft performs its actions, so review its code and use test inputs before promoting it.
180
182
 
181
183
  ```js
182
- const saved = (await oya.playbooks.list())
183
- .find((p) => p.name === playbookName);
184
+ const saved = (await oya.playbooks.list()).find((p) => p.name === playbookName);
184
185
  if (saved?.draft) {
185
186
  // Review saved.draft.code before executing it.
186
187
  await browser.play(`${playbookName}:draft`, { ...data, ...secrets }, { autoHeal: false });
@@ -195,13 +196,13 @@ Use `autoHeal: false` with `play()` or `submit({ playbook: name }, options)` to
195
196
  As in the portal-automation project's model setup script, configure the model on your Oya API key once for subsequent agent runs:
196
197
 
197
198
  ```js
198
- import { Oya } from "@oya-ai/browser";
199
+ import { Oya } from '@oya-ai/browser';
199
200
 
200
201
  const modelKey = process.env.GEMINI_API_KEY;
201
- if (!modelKey) throw new Error("Set GEMINI_API_KEY first.");
202
+ if (!modelKey) throw new Error('Set GEMINI_API_KEY first.');
202
203
  const oya = new Oya();
203
204
  await oya.config.set({
204
- llm_provider: "gemini", // LlmProvider: "openai" | "anthropic" | "gemini" | "vertex"
205
+ llm_provider: 'gemini', // LlmProvider: "openai" | "anthropic" | "gemini" | "vertex"
205
206
  openai_api_key: modelKey, // Shared field name for every supported provider.
206
207
  // chat_model: process.env.OYA_CHAT_MODEL, // Optional provider model override.
207
208
  });
@@ -210,13 +211,13 @@ await oya.config.set({
210
211
  `config.set` takes `ConfigUpdate` and `config.get()` returns `Config`, so an editor offers the valid providers and a typo fails to compile rather than silently falling back:
211
212
 
212
213
  ```ts
213
- import { Oya, type LlmProvider } from "@oya-ai/browser";
214
+ import { Oya, type LlmProvider } from '@oya-ai/browser';
214
215
 
215
- await oya.config.set({ llm_provider: "vertx" });
216
+ await oya.config.set({ llm_provider: 'vertx' });
216
217
  // ~~~~~~~ Type '"vertx"' is not assignable to type
217
218
  // 'LlmProvider'. Did you mean '"vertex"'?
218
219
 
219
- const provider: LlmProvider = "vertex"; // for your own config plumbing
220
+ const provider: LlmProvider = 'vertex'; // for your own config plumbing
220
221
  const { effective } = await oya.config.get();
221
222
  console.log(effective.baseUrl, effective.model, effective.hasLlmKey);
222
223
  ```
@@ -228,17 +229,18 @@ console.log(effective.baseUrl, effective.model, effective.hasLlmKey);
228
229
  `llm_provider: "vertex"` targets express mode, whose API keys work against a global endpoint with no GCP project or location:
229
230
 
230
231
  ```js
231
- await oya.config.set({ llm_provider: "vertex", openai_api_key: process.env.VERTEX_EXPRESS_KEY });
232
+ await oya.config.set({ llm_provider: 'vertex', openai_api_key: process.env.VERTEX_EXPRESS_KEY });
232
233
  ```
233
234
 
234
235
  For an enterprise project instead, point at its OpenAI-compatible endpoint. That path authenticates with a Google OAuth access token rather than an API key, and the token expires after about an hour, so it suits a one-off run rather than a long-lived deployment:
235
236
 
236
237
  ```js
237
238
  await oya.config.set({
238
- llm_provider: "vertex",
239
- openai_base_url: "https://us-central1-aiplatform.googleapis.com/v1/projects/PROJECT/locations/us-central1/endpoints/openapi",
239
+ llm_provider: 'vertex',
240
+ openai_base_url:
241
+ 'https://us-central1-aiplatform.googleapis.com/v1/projects/PROJECT/locations/us-central1/endpoints/openapi',
240
242
  openai_api_key: accessToken, // gcloud auth print-access-token
241
- chat_model: "google/gemini-2.5-flash", // this endpoint prefixes model ids
243
+ chat_model: 'google/gemini-2.5-flash', // this endpoint prefixes model ids
242
244
  });
243
245
  ```
244
246
 
@@ -270,21 +272,21 @@ For an embedded SSE stream of JPEG frames, use `await browser.liveStreamUrl()`.
270
272
  A persona groups a stable device fingerprint, saved login cookies, and a proxy assignment for reuse across browser sessions.
271
273
 
272
274
  ```ts
273
- import { Oya } from "@oya-ai/browser";
275
+ import { Oya } from '@oya-ai/browser';
274
276
 
275
277
  const oya = new Oya();
276
278
 
277
279
  // Create a persistent persona
278
280
  const persona = await oya.personas.create({
279
- name: "us-shopper",
280
- prefs: { platform: "MacIntel", timezone: "America/New_York", locale: "en-US" },
281
- proxy: { geo: "US" },
281
+ name: 'us-shopper',
282
+ prefs: { platform: 'MacIntel', timezone: 'America/New_York', locale: 'en-US' },
283
+ proxy: { geo: 'US' },
282
284
  maxConcurrent: 2, // Limit simultaneous sessions for this identity
283
285
  });
284
286
 
285
287
  // Launch a browser with this persona (or persona: 'auto' for least-recently-used)
286
288
  await using browser = await oya.browser.start({ persona: persona.id });
287
- await browser.goto("https://www.amazon.com");
289
+ await browser.goto('https://www.amazon.com');
288
290
 
289
291
  // Hardware attributes remain byte-identical on subsequent sessions
290
292
  console.log(persona.fingerprint.platform, persona.fingerprint.timezone);
@@ -300,18 +302,18 @@ console.log(persona.fingerprint.platform, persona.fingerprint.timezone);
300
302
  Skip messy DOM traversal. Get clean markdown and numbered interactive elements:
301
303
 
302
304
  ```ts
303
- import { Oya } from "@oya-ai/browser";
305
+ import { Oya } from '@oya-ai/browser';
304
306
 
305
307
  const oya = new Oya();
306
308
  await using browser = await oya.browser.start();
307
- await browser.goto("https://github.com/trending");
309
+ await browser.goto('https://github.com/trending');
308
310
 
309
311
  // Analyze page: returns markdown and visible numbered elements
310
312
  const { markdown, elements } = await browser.analyze();
311
313
  console.log(markdown.slice(0, 300));
312
314
 
313
315
  // Interact using numbered element IDs:
314
- const firstRepo = elements.find((el) => el.tag === "a" && el.href?.includes("/stargazers"));
316
+ const firstRepo = elements.find((el) => el.tag === 'a' && el.href?.includes('/stargazers'));
315
317
  if (firstRepo) {
316
318
  await browser.click(firstRepo.id); // clicks [data-ac-id="firstRepo.id"]
317
319
  }
@@ -322,23 +324,23 @@ if (firstRepo) {
322
324
  ## 🧩 Challenge Handling: Automated CAPTCHA & Sealed MFA
323
325
 
324
326
  ```ts
325
- import { Oya } from "@oya-ai/browser";
327
+ import { Oya } from '@oya-ai/browser';
326
328
 
327
329
  const oya = new Oya();
328
330
 
329
331
  // 1. Seal a TOTP secret on a persona (encrypted with AES-256-GCM at rest, never exposed over API)
330
- const persona = await oya.personas.create({ name: "finance-admin" });
332
+ const persona = await oya.personas.create({ name: 'finance-admin' });
331
333
  await oya.personas.setMfa(persona.id, {
332
- type: "totp",
334
+ type: 'totp',
333
335
  secret: process.env.TOTP_SECRET!,
334
336
  });
335
337
 
336
338
  await using browser = await oya.browser.start({ persona: persona.id });
337
339
 
338
340
  // 2. Clear CAPTCHAs automatically (uses vendor solver or CapSolver/2Captcha fallback)
339
- await browser.goto("https://www.google.com/recaptcha/api2/demo");
341
+ await browser.goto('https://www.google.com/recaptcha/api2/demo');
340
342
  const captcha = await browser.solveCaptcha();
341
- console.log("CAPTCHA Solved:", captcha.solved, "via", captcha.method);
343
+ console.log('CAPTCHA Solved:', captcha.solved, 'via', captcha.method);
342
344
 
343
345
  // 3. Complete Two-Factor Authentication
344
346
  await browser.goto(process.env.MFA_LOGIN_URL!);
@@ -346,7 +348,7 @@ const mfa = await browser.completeMfa();
346
348
 
347
349
  if (!mfa.completed && mfa.liveViewUrl) {
348
350
  // Hand off to human operator if interactive push notification or WebAuthn is needed
349
- console.log("Interactive handoff required at:", mfa.liveViewUrl);
351
+ console.log('Interactive handoff required at:', mfa.liveViewUrl);
350
352
  }
351
353
  ```
352
354
 
@@ -357,20 +359,20 @@ if (!mfa.completed && mfa.liveViewUrl) {
357
359
  Every browser exposes an authenticated `browser.cdpUrl` routed through Oya's gateway. Connect standard Playwright, Puppeteer, or Stagehand:
358
360
 
359
361
  ```ts
360
- import { chromium } from "playwright-core";
361
- import { Oya } from "@oya-ai/browser";
362
+ import { chromium } from 'playwright-core';
363
+ import { Oya } from '@oya-ai/browser';
362
364
 
363
365
  const oya = new Oya();
364
366
 
365
367
  // Run on any underlying provider: browserbase, steel, anchor, browseruse, or oya-cloud
366
- await using browser = await oya.browser.start({ provider: "browserbase" });
368
+ await using browser = await oya.browser.start({ provider: 'browserbase' });
367
369
 
368
370
  // Connect Playwright directly over Oya's gateway
369
371
  const context = (await chromium.connectOverCDP(browser.cdpUrl!)).contexts()[0];
370
372
  const page = context.pages()[0] ?? (await context.newPage());
371
373
 
372
- await page.goto("https://news.ycombinator.com");
373
- console.log("Page Title:", await page.title());
374
+ await page.goto('https://news.ycombinator.com');
375
+ console.log('Page Title:', await page.title());
374
376
  ```
375
377
 
376
378
  ---
@@ -380,120 +382,125 @@ console.log("Page Title:", await page.title());
380
382
  ### Initialization
381
383
 
382
384
  ```ts
383
- import { Oya } from "@oya-ai/browser";
385
+ import { Oya } from '@oya-ai/browser';
384
386
 
385
387
  const oya = new Oya({
386
- apiKey: "oya_...", // default: process.env.OYA_API_KEY
387
- baseUrl: "https://browser.getoya.ai", // default: OYA_BASE_URL, then the hosted service
388
- timeoutMs: 60_000, // per request
388
+ apiKey: 'oya_...', // default: process.env.OYA_API_KEY
389
+ baseUrl: 'https://oyabrowser.com', // default: OYA_BASE_URL, then the hosted service
390
+ timeoutMs: 60_000, // per request
389
391
  // fetch: customFetch, // any fetch-compatible implementation
390
392
  });
391
393
  ```
392
394
 
393
395
  ### Browser Operations (`oya.browser`)
394
396
 
395
- | Method | Signature | Description |
396
- |:---|:---|:---|
397
- | `start(options)` | `(options?: StartOptions) => Promise<Browser>` | Start a browser and wait until it is ready for commands |
398
- | `get(id)` | `(id: string) => Promise<Browser>` | Reattach to an existing running browser |
399
- | `list()` | `() => Promise<BrowserInfo[]>` | List all active running browser sessions |
400
- | `stop(ids \| 'all')` | `(ids: string[] \| 'all') => Promise<{ stopped: number; results: StopResult[] }>` | Stop target browsers or all browsers |
401
- | `stopAll()` | `() => Promise<number>` | Terminate all active browser sessions |
397
+ | Method | Signature | Description |
398
+ | :------------------- | :-------------------------------------------------------------------------------- | :------------------------------------------------------ |
399
+ | `start(options)` | `(options?: StartOptions) => Promise<Browser>` | Start a browser and wait until it is ready for commands |
400
+ | `get(id)` | `(id: string) => Promise<Browser>` | Reattach to an existing running browser |
401
+ | `list()` | `() => Promise<BrowserInfo[]>` | List all active running browser sessions |
402
+ | `stop(ids \| 'all')` | `(ids: string[] \| 'all') => Promise<{ stopped: number; results: StopResult[] }>` | Stop target browsers or all browsers |
403
+ | `stopAll()` | `() => Promise<number>` | Terminate all active browser sessions |
402
404
 
403
405
  #### `StartOptions`
404
406
 
405
- - `persona?: 'default' | 'auto' | string` — Assign persistent identity
407
+ - `persona?: 'default' | 'auto' | string`, Assign persistent identity
406
408
  - `provider?: 'oya-cloud' | 'oya-selfhosted' | 'browserbase' | 'steel' | 'anchor' | 'browseruse' | 'cdp'`
407
- - `wsUrl?: string`: required only for the `'cdp'` provider
409
+ - `wsUrl?: string`: required only for the `'cdp'` provider. Either the WebSocket URL, or the
410
+ plain `http://localhost:9222` that Chrome prints for `--remote-debugging-port`, which is
411
+ resolved through Chrome's own `/json/version`
408
412
  - `name?: string`: display name in the console and `oya ls`
409
- - `captcha?: 'auto' | 'off'` — Automatically solve CAPTCHAs on navigation
410
- - `queueMs?: number` — Wait duration for fleet capacity (ms)
411
- - `budgetUsd?: number` — Enforce budget limit for session
412
- - `idempotencyKey?: string` — Safe retry token
413
- - `governed?: boolean` — Enable governed session controls
414
- - `profile?: string` — Saved login profile (takes precedence over `persona`)
415
- - `priority?: 'low' | 'normal' | 'high'` — Queue priority
416
- - `policy?: { allowedHosts?, humanHosts?, region?, redactRecording? }` — Session policy
417
- - `readyTimeoutMs?: number` — Wait budget for a starting browser to connect
413
+ - `captcha?: 'auto' | 'off'`, Automatically solve CAPTCHAs on navigation
414
+ - `queueMs?: number`, Wait duration for fleet capacity (ms)
415
+ - `budgetUsd?: number`, Enforce budget limit for session
416
+ - `idempotencyKey?: string`, Safe retry token
417
+ - `governed?: boolean`, Enable governed session controls
418
+ - `profile?: string`, Saved login profile (takes precedence over `persona`)
419
+ - `priority?: 'low' | 'normal' | 'high'`, Queue priority
420
+ - `policy?: { allowedHosts?, humanHosts?, region?, redactRecording? }`, Session policy
421
+ - `readyTimeoutMs?: number`, Wait budget for a starting browser to connect
418
422
 
419
423
  ### Browser Instance Methods (`browser.*`)
420
424
 
421
- | Method | Returns | Description |
422
- |:---|:---|:---|
423
- | `goto(url)` | `Promise<void>` | Navigate to URL (with optional auto-CAPTCHA) |
424
- | `ask(prompt, { data?, secrets? }?)` | `Promise<string>` | Natural-language AI driving using key's configured model |
425
- | `analyze()` | `Promise<Analysis>` | Returns markdown representation and numbered elements |
426
- | `elements()` | `Promise<Element[]>` | Returns only visible interactable elements |
427
- | `click(elementId)` | `Promise<void>` | Click element by numeric ID from `analyze()` |
428
- | `type(elementId, text)` | `Promise<{ suggestions_visible?: boolean }>` | Type text into specified element |
429
- | `pressKey(key)` | `Promise<void>` | Dispatch keyboard key event (e.g. `'Enter'`) |
430
- | `scroll(dir, amount?, at?)` | `Promise<void>` | Scroll `'up' \| 'down' \| 'top' \| 'bottom'` |
431
- | `waitFor(selector, timeout?)`| `Promise<void>` | Wait for DOM selector |
432
- | `screenshot()` | `Promise<string>` | Capture page as base64 image data URL |
433
- | `url()` | `Promise<string>` | Current active tab URL |
434
- | `tabs()` | `Promise<Tab[]>` | List open tabs |
435
- | `openTab(url?)` | `Promise<string>` | Open a new tab |
436
- | `switchTab(tabId)` | `Promise<void>` | Switch active tab |
437
- | `closeTab(tabId)` | `Promise<void>` | Close target tab |
438
- | `solveCaptcha()` | `Promise<CaptchaResult>` | Detect and solve on-screen CAPTCHA |
439
- | `completeMfa()` | `Promise<MfaResult>` | Resolve TOTP/SMS MFA or return `liveViewUrl` |
440
- | `liveViewUrl()` | `string` | Dashboard link for this browser |
441
- | `liveStreamUrl()` | `Promise<string>` | SSE frame stream URL with a single-use ticket |
442
- | `shareUrl(options?)` | `Promise<{ url, id, expiresAt }>` | Expiring browser share link; optional control access |
443
- | `revokeShare(id)` | `Promise<void>` | Revoke a share link |
444
- | `submit(task, options?)` | `Promise<Run>` | Background prompt or playbook with callbacks |
445
- | `toPlaybook(name)` | `Promise<Playbook>` | Save the latest agent flow and export Playwright code |
446
- | `play(name, data?, { autoHeal? }?)` | `Promise<PlayResult>` | Replay a saved flow |
447
- | `status()` | `Promise<BrowserDetail>` | Instance metrics, health, and recent activity log |
448
- | `stop()` | `Promise<StopResult>` | Tear down sandbox and release CDP session |
425
+ | Method | Returns | Description |
426
+ | :---------------------------------- | :------------------------------------------- | :------------------------------------------------------- |
427
+ | `goto(url)` | `Promise<void>` | Navigate to URL (with optional auto-CAPTCHA) |
428
+ | `ask(prompt, { data?, secrets? }?)` | `Promise<string>` | Natural-language AI driving using key's configured model |
429
+ | `analyze()` | `Promise<Analysis>` | Returns markdown representation and numbered elements |
430
+ | `elements()` | `Promise<Element[]>` | Returns only visible interactable elements |
431
+ | `click(elementId)` | `Promise<void>` | Click element by numeric ID from `analyze()` |
432
+ | `type(elementId, text)` | `Promise<{ suggestions_visible?: boolean }>` | Type text into specified element |
433
+ | `pressKey(key)` | `Promise<void>` | Dispatch keyboard key event (e.g. `'Enter'`) |
434
+ | `scroll(dir, amount?, at?)` | `Promise<void>` | Scroll `'up' \| 'down' \| 'top' \| 'bottom'` |
435
+ | `waitFor(selector, timeout?)` | `Promise<void>` | Wait for DOM selector |
436
+ | `screenshot()` | `Promise<string>` | Capture page as base64 image data URL |
437
+ | `url()` | `Promise<string>` | Current active tab URL |
438
+ | `tabs()` | `Promise<Tab[]>` | List open tabs |
439
+ | `openTab(url?)` | `Promise<string>` | Open a new tab |
440
+ | `switchTab(tabId)` | `Promise<void>` | Switch active tab |
441
+ | `closeTab(tabId)` | `Promise<void>` | Close target tab |
442
+ | `solveCaptcha()` | `Promise<CaptchaResult>` | Detect and solve on-screen CAPTCHA |
443
+ | `completeMfa()` | `Promise<MfaResult>` | Resolve TOTP/SMS MFA or return `liveViewUrl` |
444
+ | `liveViewUrl()` | `string` | Dashboard link for this browser |
445
+ | `liveStreamUrl()` | `Promise<string>` | SSE frame stream URL with a single-use ticket |
446
+ | `shareUrl(options?)` | `Promise<{ url, id, expiresAt }>` | Expiring browser share link; optional control access |
447
+ | `revokeShare(id)` | `Promise<void>` | Revoke a share link |
448
+ | `submit(task, options?)` | `Promise<Run>` | Background prompt or playbook with callbacks |
449
+ | `toPlaybook(name)` | `Promise<Playbook>` | Save the latest agent flow and export Playwright code |
450
+ | `play(name, data?, { autoHeal? }?)` | `Promise<PlayResult>` | Replay a saved flow |
451
+ | `status()` | `Promise<BrowserDetail>` | Instance metrics, health, and recent activity log |
452
+ | `stop()` | `Promise<StopResult>` | Tear down sandbox and release CDP session |
449
453
 
450
454
  ### Profile and persona management (`oya.profiles`, `oya.personas`)
451
455
 
452
456
  `oya.profiles` exposes the same methods as `oya.personas`; the persona name remains available for existing integrations.
453
457
 
454
- | Method | Description |
455
- |:---|:---|
456
- | `create({ name?, prefs?, proxy?, maxConcurrent? })` | Create new deterministic device identity |
457
- | `list()` | List all saved personas and active concurrency |
458
- | `get(id)` | Get persona profile details |
459
- | `update(id, changes)` | Update name, concurrency limit, or proxy geo |
460
- | `clone(id, options)` | Create fresh persona with same device traits but empty cookie jar |
461
- | `preview(prefs)` | Preview generated hardware fingerprint before creating |
462
- | `options()` | Available platforms, timezones, and valid locales |
463
- | `pinProxy(id, proxyId)` | Bind persona permanently to a residential proxy exit node |
464
- | `remove(id)` | Delete persona and associated cookie jar |
465
- | `setMfa(id, config)` | Store TOTP secret (sealed at rest with AES-256-GCM) |
466
- | `clearMfa(id)` | Remove MFA secret from persona |
458
+ | Method | Description |
459
+ | :-------------------------------------------------- | :---------------------------------------------------------------- |
460
+ | `create({ name?, prefs?, proxy?, maxConcurrent? })` | Create new deterministic device identity |
461
+ | `list()` | List all saved personas and active concurrency |
462
+ | `get(id)` | Get persona profile details |
463
+ | `update(id, changes)` | Update name, concurrency limit, or proxy geo |
464
+ | `clone(id, options)` | Create fresh persona with same device traits but empty cookie jar |
465
+ | `preview(prefs)` | Preview generated hardware fingerprint before creating |
466
+ | `options()` | Available platforms, timezones, and valid locales |
467
+ | `pinProxy(id, proxyId)` | Bind persona permanently to a residential proxy exit node |
468
+ | `remove(id)` | Delete persona and associated cookie jar |
469
+ | `setMfa(id, config)` | Store TOTP secret (sealed at rest with AES-256-GCM) |
470
+ | `clearMfa(id)` | Remove MFA secret from persona |
467
471
 
468
472
  ### Proxies (`oya.proxies`)
469
473
 
470
- | Method | Description |
471
- |:---|:---|
472
- | `create({ url, label?, geo?, kind?, maxPersonas? })` | Add a proxy from your vendor. Credentials are encrypted and never returned |
473
- | `list()` | Your proxies and shared ones, with exit IP, health and how many personas use each |
474
- | `check()` | Dial every proxy and record its real exit IP |
475
- | `remove(id)` | Delete a proxy and unpin the personas on it |
474
+ | Method | Description |
475
+ | :--------------------------------------------------- | :-------------------------------------------------------------------------------- |
476
+ | `create({ url, label?, geo?, kind?, maxPersonas? })` | Add a proxy from your vendor. Credentials are encrypted and never returned |
477
+ | `list()` | Your proxies and shared ones, with exit IP, health and how many personas use each |
478
+ | `check()` | Dial every proxy and record its real exit IP |
479
+ | `remove(id)` | Delete a proxy and unpin the personas on it |
476
480
 
477
481
  ```ts
478
482
  const proxy = await oya.proxies.create({
479
- url: "http://user:pass_session-shopper1@gate.vendor.com:7000", // one sticky session per persona
480
- label: "us-shopper-1", geo: "US", kind: "residential", maxPersonas: 1,
483
+ url: 'http://user:pass_session-shopper1@gate.vendor.com:7000', // one sticky session per persona
484
+ label: 'us-shopper-1',
485
+ geo: 'US',
486
+ kind: 'residential',
487
+ maxPersonas: 1,
481
488
  });
482
489
  await oya.personas.pinProxy(persona.id, proxy.id);
483
490
  ```
484
491
 
485
492
  ### Durable Governance & Control (`oya.control`)
486
493
 
487
- | Method | Description |
488
- |:---|:---|
489
- | `overview()` | Fleet overview, spend, sessions, and active rate cards |
490
- | `sessions()` | List all durable sessions (including cleanup-pending) |
491
- | `session(id)` | Get detailed session execution state |
492
- | `takeover(id, 'acquire' \| 'release' \| 'resume')` | Manage human control leases |
493
- | `ticket(id)` | Generate single-use connection ticket for secure handoff |
494
- | `events(after?)` | Read audit events and a pagination cursor |
495
- | `createCredential(options)` | Mint scoped service credential (`viewer` / `operator` / `administrator`) |
496
- | `createWebhook(url, types)` | Register HMAC-signed webhook for fleet lifecycle events |
494
+ | Method | Description |
495
+ | :------------------------------------------------- | :----------------------------------------------------------------------- |
496
+ | `overview()` | Fleet overview, spend, sessions, and active rate cards |
497
+ | `sessions()` | List all durable sessions (including cleanup-pending) |
498
+ | `session(id)` | Get detailed session execution state |
499
+ | `takeover(id, 'acquire' \| 'release' \| 'resume')` | Manage human control leases |
500
+ | `ticket(id)` | Generate single-use connection ticket for secure handoff |
501
+ | `events(after?)` | Read audit events and a pagination cursor |
502
+ | `createCredential(options)` | Mint scoped service credential (`viewer` / `operator` / `administrator`) |
503
+ | `createWebhook(url, types)` | Register HMAC-signed webhook for fleet lifecycle events |
497
504
 
498
505
  ---
499
506
 
@@ -502,11 +509,11 @@ await oya.personas.pinProxy(persona.id, proxy.id);
502
509
  API error responses and failed browser commands throw `OyaError`. Network failures, request timeouts, and configuration errors may throw other error types:
503
510
 
504
511
  ```ts
505
- import { Oya, OyaError } from "@oya-ai/browser";
512
+ import { Oya, OyaError } from '@oya-ai/browser';
506
513
 
507
514
  try {
508
515
  const oya = new Oya();
509
- await oya.browser.start({ persona: "invalid-id" });
516
+ await oya.browser.start({ persona: 'invalid-id' });
510
517
  } catch (err) {
511
518
  if (err instanceof OyaError) {
512
519
  console.error(`Oya API Error (${err.status}):`, err.message);
package/dist/index.cjs CHANGED
@@ -45,8 +45,30 @@ var OyaError = class extends Error {
45
45
  }
46
46
  };
47
47
 
48
+ // src/cli-config.ts
49
+ var node = () => globalThis.process;
50
+ function builtin(name) {
51
+ const get = node()?.getBuiltinModule;
52
+ return typeof get === "function" ? get.call(node(), name) ?? null : null;
53
+ }
54
+ function configFile() {
55
+ const os = builtin("node:os");
56
+ const home = node()?.env?.OYA_CONFIG_HOME || (os ? `${os.homedir()}/.oya` : null);
57
+ return home ? `${home}/config.json` : null;
58
+ }
59
+ function savedConfig() {
60
+ try {
61
+ const fs = builtin("node:fs");
62
+ const file2 = configFile();
63
+ if (!fs || !file2 || !fs.existsSync(file2)) return {};
64
+ return JSON.parse(fs.readFileSync(file2, "utf8"));
65
+ } catch {
66
+ return {};
67
+ }
68
+ }
69
+
48
70
  // src/constants.ts
49
- var DEFAULT_BASE_URL = "https://browser.getoya.ai";
71
+ var DEFAULT_BASE_URL = "https://oyabrowser.com";
50
72
  var DEFAULT_TIMEOUT_MS = 6e4;
51
73
  var START_TIMEOUT_MS = 12e4;
52
74
  var READY_TIMEOUT_MS = 12e4;
@@ -95,10 +117,31 @@ var Http = class {
95
117
  fetchImpl;
96
118
  /** Sends one request and returns the parsed answer, or throws an OyaError when it failed. */
97
119
  async request(method, path, body, timeoutMs = this.timeoutMs, headers = {}) {
98
- const init = requestInit(method, body, timeoutMs, { Authorization: `Bearer ${this.apiKey}`, ...headers });
99
- return readAnswer(await this.fetchImpl(`${this.baseUrl}${path}`, init), `${method} ${path}`, this.baseUrl);
120
+ const res = await this.send(`${this.baseUrl}${path}`, this.init(method, body, timeoutMs, headers), timeoutMs);
121
+ return readAnswer(res, `${method} ${path}`, this.baseUrl);
122
+ }
123
+ /** The fetch options for one call, with this client's key on them. */
124
+ init(method, body, timeoutMs, headers) {
125
+ return requestInit(method, body, timeoutMs, { Authorization: `Bearer ${this.apiKey}`, ...headers });
126
+ }
127
+ /**
128
+ * fetch's own failures say only "fetch failed" or "The operation was
129
+ * aborted", which leaves a reader guessing at the address, the port and
130
+ * whether anything is listening. Say which it was.
131
+ */
132
+ async send(url, init, timeoutMs) {
133
+ try {
134
+ return await this.fetchImpl(url, init);
135
+ } catch (err) {
136
+ throw new OyaError(unreachable(this.baseUrl, timeoutMs, err), 0, { error: String(err?.message) });
137
+ }
100
138
  }
101
139
  };
140
+ function unreachable(baseUrl, timeoutMs, err) {
141
+ const cause = err?.name === "TimeoutError" || /abort/i.test(String(err?.message));
142
+ if (cause) return `No answer from ${baseUrl} within ${timeoutMs}ms. Is it reachable, and is the call this slow?`;
143
+ return `Could not reach ${baseUrl}. Is the server running, and is OYA_BASE_URL right?`;
144
+ }
102
145
  function requestInit(method, body, timeoutMs, headers) {
103
146
  const json = body === void 0 ? {} : { "Content-Type": "application/json" };
104
147
  const payload = body === void 0 ? void 0 : JSON.stringify(body);
@@ -125,13 +168,12 @@ function failure(call2, status, payload, baseUrl) {
125
168
  }
126
169
  var env = (name) => globalThis.process?.env?.[name];
127
170
  function createHttp(options) {
128
- const apiKey = options.apiKey || env("OYA_API_KEY");
129
- if (!apiKey) {
130
- throw new Error("No API key. Pass { apiKey } or set OYA_API_KEY \u2014 run `oya login` to get one.");
131
- }
132
- const baseUrl = (options.baseUrl || env("OYA_BASE_URL") || DEFAULT_BASE_URL).replace(/\/+$/, "");
171
+ const saved = savedConfig();
172
+ const apiKey = options.apiKey || env("OYA_API_KEY") || saved.apiKey;
173
+ if (!apiKey) throw new Error("No API key. Pass { apiKey }, set OYA_API_KEY, or run `npx @oya-ai/cli login`.");
174
+ const baseUrl = (options.baseUrl || env("OYA_BASE_URL") || saved.baseUrl || DEFAULT_BASE_URL).replace(/\/+$/, "");
133
175
  const fetchImpl = options.fetch || globalThis.fetch;
134
- if (!fetchImpl) throw new Error("No fetch available \u2014 pass { fetch } or use Node 18+.");
176
+ if (!fetchImpl) throw new Error("No fetch available, pass { fetch } or use Node 18+.");
135
177
  return new Http(baseUrl, apiKey, options.timeoutMs ?? DEFAULT_TIMEOUT_MS, fetchImpl.bind(globalThis));
136
178
  }
137
179
 
@@ -231,10 +273,8 @@ var Browser = class {
231
273
  constructor(http, info, autoCaptcha) {
232
274
  this.http = http;
233
275
  this.autoCaptcha = autoCaptcha;
234
- this.id = info.id;
235
- this.provider = info.provider;
236
- this.persona = info.persona;
237
- this.cdpUrl = info.cdpUrl;
276
+ ({ id: this.id, provider: this.provider, persona: this.persona, cdpUrl: this.cdpUrl } = info);
277
+ this.reused = info.reused === true;
238
278
  }
239
279
  http;
240
280
  autoCaptcha;
@@ -246,6 +286,8 @@ var Browser = class {
246
286
  persona;
247
287
  /** Point Playwright, Puppeteer or browser-use here. */
248
288
  cdpUrl;
289
+ /** True when `start()` handed over a browser that was already running, such as the desktop app. */
290
+ reused;
249
291
  /** Runs one browser command; a command that ran and failed throws. */
250
292
  async command(action, params = {}, timeoutMs) {
251
293
  const path = `/api/browsers/${this.id}/command`;
@@ -397,7 +439,7 @@ var Browser = class {
397
439
  /**
398
440
  * The SSE stream of JPEG frames, for embedding in your own UI. EventSource
399
441
  * cannot set headers, so the URL carries a connection ticket: single use,
400
- * 60 seconds. Mint one per viewer — the first connection spends it.
442
+ * 60 seconds. Mint one per viewer, the first connection spends it.
401
443
  */
402
444
  async liveStreamUrl() {
403
445
  const path = `/api/control/sessions/${encodeURIComponent(this.id)}/ticket`;
@@ -433,13 +475,14 @@ var Browser = class {
433
475
  * CDP session is handed back to its provider, a desktop browser disconnects.
434
476
  */
435
477
  stop() {
478
+ if (this.reused) return Promise.resolve({ id: this.id, ok: true, reused: true });
436
479
  return this.http.request("POST", `/api/browsers/${this.id}/stop`, {}, STOP_TIMEOUT_MS);
437
480
  }
438
481
  /** `await using browser = await oya.browser.start()` stops it however the block exits, errors included. */
439
482
  async [Symbol.asyncDispose]() {
440
483
  await this.stop();
441
484
  }
442
- /** @deprecated use stop() — close() only dropped the socket, and a cloud browser redialled. */
485
+ /** @deprecated use stop(), close() only dropped the socket, and a cloud browser redialled. */
443
486
  async close() {
444
487
  await this.stop();
445
488
  }
@@ -606,11 +649,11 @@ var identityCalls = (http) => ({
606
649
  /** One persona. */
607
650
  get: (id) => http().request("GET", `/api/personas/${id}`),
608
651
  /**
609
- * Create an identity. The device — platform, timezone, locale — is chosen
652
+ * Create an identity. The device, platform, timezone, locale, is chosen
610
653
  * here and fixed for its life; `preview()` shows what a choice produces.
611
654
  */
612
655
  create: (options = {}) => http().request("POST", "/api/personas", options),
613
- /** Name, concurrency cap and proxy hint. Never the device — clone for that. */
656
+ /** Name, concurrency cap and proxy hint. Never the device, clone for that. */
614
657
  update: (id, changes) => http().request("PUT", `/api/personas/${id}`, changes),
615
658
  /** A new persona of the same kind of device: same choices, fresh identity, empty jar. */
616
659
  clone: (id, options = {}) => http().request("POST", `/api/personas/${id}/clone`, options)
package/dist/index.d.cts CHANGED
@@ -32,16 +32,19 @@ interface StartOptions {
32
32
  /**
33
33
  * Which identity to run as. A persona is one device: fingerprint, cookie jar
34
34
  * and proxy bound together and stable for its life.
35
- * 'default' — this key's own persona (the default)
36
- * 'auto' — the least recently used persona under its concurrency cap
37
- * <id> — a specific persona
35
+ * 'default', this key's own persona (the default)
36
+ * 'auto' , the least recently used persona under its concurrency cap
37
+ * <id> , a specific persona
38
38
  */
39
39
  persona?: 'default' | 'auto' | (string & {});
40
40
  /** Solve CAPTCHAs as they appear rather than waiting to be asked. */
41
41
  captcha?: 'auto' | 'off';
42
42
  /** Override the key's configured provider for this browser only. */
43
43
  provider?: Provider;
44
- /** Required only for the 'cdp' provider. */
44
+ /**
45
+ * Required only for the 'cdp' provider: the CDP WebSocket URL, or the plain
46
+ * `http://localhost:9222` Chrome prints for `--remote-debugging-port`.
47
+ */
45
48
  wsUrl?: string;
46
49
  /** A label for the fleet listing. */
47
50
  name?: string;
@@ -62,6 +65,8 @@ interface StartResult {
62
65
  cdpUrl?: string;
63
66
  /** Anything the server wants the caller to know about this start. */
64
67
  note?: string;
68
+ /** True when this browser was already running and was handed over rather than started. */
69
+ reused?: boolean;
65
70
  }
66
71
  /** One element on the page, numbered by `analyze()`. */
67
72
  interface Element {
@@ -212,6 +217,8 @@ interface StopResult {
212
217
  sandboxRemoved?: boolean | null;
213
218
  /** Why it did not stop. */
214
219
  error?: string;
220
+ /** True when nothing was stopped because the browser was only borrowed. */
221
+ reused?: boolean;
215
222
  }
216
223
  /** One browser in the fleet listing. */
217
224
  interface BrowserInfo {
@@ -274,7 +281,7 @@ type CaptchaSolver = 'capsolver' | '2captcha' | '';
274
281
  interface ConfigUpdate {
275
282
  /** Which LLM vendor `ask()` and the chat API call. */
276
283
  llm_provider?: LlmProvider | null;
277
- /** The credential for whichever `llm_provider` is set — the field name is shared. */
284
+ /** The credential for whichever `llm_provider` is set, the field name is shared. */
278
285
  openai_api_key?: string | null;
279
286
  /** Only honoured alongside this key's own `openai_api_key`. */
280
287
  openai_base_url?: string | null;
@@ -360,7 +367,7 @@ interface Playbook {
360
367
  name: string;
361
368
  /** Inputs `play()` accepts; any left out reuse the recorded value. */
362
369
  variables: string[];
363
- /** What each variable was recorded with. A secret has none — it never left the page. */
370
+ /** What each variable was recorded with. A secret has none, it never left the page. */
364
371
  defaults: Record<string, string>;
365
372
  /** How many steps it replays. */
366
373
  steps: number;
@@ -629,7 +636,7 @@ interface PersonaInfo {
629
636
  * persona-wide default.
630
637
  *
631
638
  * `gmail` and `graph` read the code straight out of a mailbox. `email` and `sms`
632
- * poll an endpoint you host — set `x-oya-received-at` on its response (epoch ms)
639
+ * poll an endpoint you host, set `x-oya-received-at` on its response (epoch ms)
633
640
  * and a code from a previous run will never be reused.
634
641
  *
635
642
  * The code is pulled out of the message by your own configured LLM, because
@@ -794,7 +801,7 @@ interface ControlOverview {
794
801
  interface OyaOptions {
795
802
  /** Defaults to OYA_API_KEY. */
796
803
  apiKey?: string;
797
- /** Defaults to OYA_BASE_URL, then https://browser.getoya.ai. */
804
+ /** Defaults to OYA_BASE_URL, then https://oyabrowser.com. */
798
805
  baseUrl?: string;
799
806
  /** Per-request timeout. Navigation gets its own, longer budget. */
800
807
  timeoutMs?: number;
@@ -812,6 +819,14 @@ declare class Http {
812
819
  constructor(baseUrl: string, apiKey: string, timeoutMs: number, fetchImpl: typeof globalThis.fetch);
813
820
  /** Sends one request and returns the parsed answer, or throws an OyaError when it failed. */
814
821
  request<T>(method: string, path: string, body?: unknown, timeoutMs?: number, headers?: Record<string, string>): Promise<T>;
822
+ /** The fetch options for one call, with this client's key on them. */
823
+ private init;
824
+ /**
825
+ * fetch's own failures say only "fetch failed" or "The operation was
826
+ * aborted", which leaves a reader guessing at the address, the port and
827
+ * whether anything is listening. Say which it was.
828
+ */
829
+ private send;
815
830
  }
816
831
 
817
832
  /**
@@ -1086,7 +1101,7 @@ declare class Run {
1086
1101
  /**
1087
1102
  * One running browser.
1088
1103
  *
1089
- * Element ids come from `analyze()` and are only valid until the page changes —
1104
+ * Element ids come from `analyze()` and are only valid until the page changes,
1090
1105
  * the same contract the agent tools use. `click(13)` after a navigation is a
1091
1106
  * bug; call `analyze()` again.
1092
1107
  */
@@ -1101,6 +1116,8 @@ declare class Browser {
1101
1116
  readonly persona: string;
1102
1117
  /** Point Playwright, Puppeteer or browser-use here. */
1103
1118
  readonly cdpUrl?: string;
1119
+ /** True when `start()` handed over a browser that was already running, such as the desktop app. */
1120
+ readonly reused: boolean;
1104
1121
  /** Wraps a started browser; `autoCaptcha` solves CAPTCHAs after every `goto()`. */
1105
1122
  constructor(http: Http, info: StartResult, autoCaptcha: boolean);
1106
1123
  /** Runs one browser command; a command that ran and failed throws. */
@@ -1191,7 +1208,7 @@ declare class Browser {
1191
1208
  /**
1192
1209
  * The SSE stream of JPEG frames, for embedding in your own UI. EventSource
1193
1210
  * cannot set headers, so the URL carries a connection ticket: single use,
1194
- * 60 seconds. Mint one per viewer — the first connection spends it.
1211
+ * 60 seconds. Mint one per viewer, the first connection spends it.
1195
1212
  */
1196
1213
  liveStreamUrl(): Promise<string>;
1197
1214
  /**
@@ -1217,7 +1234,7 @@ declare class Browser {
1217
1234
  stop(): Promise<StopResult>;
1218
1235
  /** `await using browser = await oya.browser.start()` stops it however the block exits, errors included. */
1219
1236
  [Symbol.asyncDispose](): Promise<void>;
1220
- /** @deprecated use stop() — close() only dropped the socket, and a cloud browser redialled. */
1237
+ /** @deprecated use stop(), close() only dropped the socket, and a cloud browser redialled. */
1221
1238
  close(): Promise<void>;
1222
1239
  }
1223
1240
 
@@ -1227,7 +1244,7 @@ declare class Browser {
1227
1244
  * await browser.ask('Attach my resume', { data: { resume: await file('./cv.pdf') } });
1228
1245
  *
1229
1246
  * The bytes ride inline in the run request, so the agent's `upload_file` tool can put
1230
- * them into a page's file input. `secrets` cannot hold one — a file is never typed
1247
+ * them into a page's file input. `secrets` cannot hold one, a file is never typed
1231
1248
  * through a placeholder, so there is nothing to hide.
1232
1249
  */
1233
1250
 
package/dist/index.d.ts CHANGED
@@ -32,16 +32,19 @@ interface StartOptions {
32
32
  /**
33
33
  * Which identity to run as. A persona is one device: fingerprint, cookie jar
34
34
  * and proxy bound together and stable for its life.
35
- * 'default' — this key's own persona (the default)
36
- * 'auto' — the least recently used persona under its concurrency cap
37
- * <id> — a specific persona
35
+ * 'default', this key's own persona (the default)
36
+ * 'auto' , the least recently used persona under its concurrency cap
37
+ * <id> , a specific persona
38
38
  */
39
39
  persona?: 'default' | 'auto' | (string & {});
40
40
  /** Solve CAPTCHAs as they appear rather than waiting to be asked. */
41
41
  captcha?: 'auto' | 'off';
42
42
  /** Override the key's configured provider for this browser only. */
43
43
  provider?: Provider;
44
- /** Required only for the 'cdp' provider. */
44
+ /**
45
+ * Required only for the 'cdp' provider: the CDP WebSocket URL, or the plain
46
+ * `http://localhost:9222` Chrome prints for `--remote-debugging-port`.
47
+ */
45
48
  wsUrl?: string;
46
49
  /** A label for the fleet listing. */
47
50
  name?: string;
@@ -62,6 +65,8 @@ interface StartResult {
62
65
  cdpUrl?: string;
63
66
  /** Anything the server wants the caller to know about this start. */
64
67
  note?: string;
68
+ /** True when this browser was already running and was handed over rather than started. */
69
+ reused?: boolean;
65
70
  }
66
71
  /** One element on the page, numbered by `analyze()`. */
67
72
  interface Element {
@@ -212,6 +217,8 @@ interface StopResult {
212
217
  sandboxRemoved?: boolean | null;
213
218
  /** Why it did not stop. */
214
219
  error?: string;
220
+ /** True when nothing was stopped because the browser was only borrowed. */
221
+ reused?: boolean;
215
222
  }
216
223
  /** One browser in the fleet listing. */
217
224
  interface BrowserInfo {
@@ -274,7 +281,7 @@ type CaptchaSolver = 'capsolver' | '2captcha' | '';
274
281
  interface ConfigUpdate {
275
282
  /** Which LLM vendor `ask()` and the chat API call. */
276
283
  llm_provider?: LlmProvider | null;
277
- /** The credential for whichever `llm_provider` is set — the field name is shared. */
284
+ /** The credential for whichever `llm_provider` is set, the field name is shared. */
278
285
  openai_api_key?: string | null;
279
286
  /** Only honoured alongside this key's own `openai_api_key`. */
280
287
  openai_base_url?: string | null;
@@ -360,7 +367,7 @@ interface Playbook {
360
367
  name: string;
361
368
  /** Inputs `play()` accepts; any left out reuse the recorded value. */
362
369
  variables: string[];
363
- /** What each variable was recorded with. A secret has none — it never left the page. */
370
+ /** What each variable was recorded with. A secret has none, it never left the page. */
364
371
  defaults: Record<string, string>;
365
372
  /** How many steps it replays. */
366
373
  steps: number;
@@ -629,7 +636,7 @@ interface PersonaInfo {
629
636
  * persona-wide default.
630
637
  *
631
638
  * `gmail` and `graph` read the code straight out of a mailbox. `email` and `sms`
632
- * poll an endpoint you host — set `x-oya-received-at` on its response (epoch ms)
639
+ * poll an endpoint you host, set `x-oya-received-at` on its response (epoch ms)
633
640
  * and a code from a previous run will never be reused.
634
641
  *
635
642
  * The code is pulled out of the message by your own configured LLM, because
@@ -794,7 +801,7 @@ interface ControlOverview {
794
801
  interface OyaOptions {
795
802
  /** Defaults to OYA_API_KEY. */
796
803
  apiKey?: string;
797
- /** Defaults to OYA_BASE_URL, then https://browser.getoya.ai. */
804
+ /** Defaults to OYA_BASE_URL, then https://oyabrowser.com. */
798
805
  baseUrl?: string;
799
806
  /** Per-request timeout. Navigation gets its own, longer budget. */
800
807
  timeoutMs?: number;
@@ -812,6 +819,14 @@ declare class Http {
812
819
  constructor(baseUrl: string, apiKey: string, timeoutMs: number, fetchImpl: typeof globalThis.fetch);
813
820
  /** Sends one request and returns the parsed answer, or throws an OyaError when it failed. */
814
821
  request<T>(method: string, path: string, body?: unknown, timeoutMs?: number, headers?: Record<string, string>): Promise<T>;
822
+ /** The fetch options for one call, with this client's key on them. */
823
+ private init;
824
+ /**
825
+ * fetch's own failures say only "fetch failed" or "The operation was
826
+ * aborted", which leaves a reader guessing at the address, the port and
827
+ * whether anything is listening. Say which it was.
828
+ */
829
+ private send;
815
830
  }
816
831
 
817
832
  /**
@@ -1086,7 +1101,7 @@ declare class Run {
1086
1101
  /**
1087
1102
  * One running browser.
1088
1103
  *
1089
- * Element ids come from `analyze()` and are only valid until the page changes —
1104
+ * Element ids come from `analyze()` and are only valid until the page changes,
1090
1105
  * the same contract the agent tools use. `click(13)` after a navigation is a
1091
1106
  * bug; call `analyze()` again.
1092
1107
  */
@@ -1101,6 +1116,8 @@ declare class Browser {
1101
1116
  readonly persona: string;
1102
1117
  /** Point Playwright, Puppeteer or browser-use here. */
1103
1118
  readonly cdpUrl?: string;
1119
+ /** True when `start()` handed over a browser that was already running, such as the desktop app. */
1120
+ readonly reused: boolean;
1104
1121
  /** Wraps a started browser; `autoCaptcha` solves CAPTCHAs after every `goto()`. */
1105
1122
  constructor(http: Http, info: StartResult, autoCaptcha: boolean);
1106
1123
  /** Runs one browser command; a command that ran and failed throws. */
@@ -1191,7 +1208,7 @@ declare class Browser {
1191
1208
  /**
1192
1209
  * The SSE stream of JPEG frames, for embedding in your own UI. EventSource
1193
1210
  * cannot set headers, so the URL carries a connection ticket: single use,
1194
- * 60 seconds. Mint one per viewer — the first connection spends it.
1211
+ * 60 seconds. Mint one per viewer, the first connection spends it.
1195
1212
  */
1196
1213
  liveStreamUrl(): Promise<string>;
1197
1214
  /**
@@ -1217,7 +1234,7 @@ declare class Browser {
1217
1234
  stop(): Promise<StopResult>;
1218
1235
  /** `await using browser = await oya.browser.start()` stops it however the block exits, errors included. */
1219
1236
  [Symbol.asyncDispose](): Promise<void>;
1220
- /** @deprecated use stop() — close() only dropped the socket, and a cloud browser redialled. */
1237
+ /** @deprecated use stop(), close() only dropped the socket, and a cloud browser redialled. */
1221
1238
  close(): Promise<void>;
1222
1239
  }
1223
1240
 
@@ -1227,7 +1244,7 @@ declare class Browser {
1227
1244
  * await browser.ask('Attach my resume', { data: { resume: await file('./cv.pdf') } });
1228
1245
  *
1229
1246
  * The bytes ride inline in the run request, so the agent's `upload_file` tool can put
1230
- * them into a page's file input. `secrets` cannot hold one — a file is never typed
1247
+ * them into a page's file input. `secrets` cannot hold one, a file is never typed
1231
1248
  * through a placeholder, so there is nothing to hide.
1232
1249
  */
1233
1250
 
package/dist/index.js CHANGED
@@ -13,8 +13,30 @@ var OyaError = class extends Error {
13
13
  }
14
14
  };
15
15
 
16
+ // src/cli-config.ts
17
+ var node = () => globalThis.process;
18
+ function builtin(name) {
19
+ const get = node()?.getBuiltinModule;
20
+ return typeof get === "function" ? get.call(node(), name) ?? null : null;
21
+ }
22
+ function configFile() {
23
+ const os = builtin("node:os");
24
+ const home = node()?.env?.OYA_CONFIG_HOME || (os ? `${os.homedir()}/.oya` : null);
25
+ return home ? `${home}/config.json` : null;
26
+ }
27
+ function savedConfig() {
28
+ try {
29
+ const fs = builtin("node:fs");
30
+ const file2 = configFile();
31
+ if (!fs || !file2 || !fs.existsSync(file2)) return {};
32
+ return JSON.parse(fs.readFileSync(file2, "utf8"));
33
+ } catch {
34
+ return {};
35
+ }
36
+ }
37
+
16
38
  // src/constants.ts
17
- var DEFAULT_BASE_URL = "https://browser.getoya.ai";
39
+ var DEFAULT_BASE_URL = "https://oyabrowser.com";
18
40
  var DEFAULT_TIMEOUT_MS = 6e4;
19
41
  var START_TIMEOUT_MS = 12e4;
20
42
  var READY_TIMEOUT_MS = 12e4;
@@ -63,10 +85,31 @@ var Http = class {
63
85
  fetchImpl;
64
86
  /** Sends one request and returns the parsed answer, or throws an OyaError when it failed. */
65
87
  async request(method, path, body, timeoutMs = this.timeoutMs, headers = {}) {
66
- const init = requestInit(method, body, timeoutMs, { Authorization: `Bearer ${this.apiKey}`, ...headers });
67
- return readAnswer(await this.fetchImpl(`${this.baseUrl}${path}`, init), `${method} ${path}`, this.baseUrl);
88
+ const res = await this.send(`${this.baseUrl}${path}`, this.init(method, body, timeoutMs, headers), timeoutMs);
89
+ return readAnswer(res, `${method} ${path}`, this.baseUrl);
90
+ }
91
+ /** The fetch options for one call, with this client's key on them. */
92
+ init(method, body, timeoutMs, headers) {
93
+ return requestInit(method, body, timeoutMs, { Authorization: `Bearer ${this.apiKey}`, ...headers });
94
+ }
95
+ /**
96
+ * fetch's own failures say only "fetch failed" or "The operation was
97
+ * aborted", which leaves a reader guessing at the address, the port and
98
+ * whether anything is listening. Say which it was.
99
+ */
100
+ async send(url, init, timeoutMs) {
101
+ try {
102
+ return await this.fetchImpl(url, init);
103
+ } catch (err) {
104
+ throw new OyaError(unreachable(this.baseUrl, timeoutMs, err), 0, { error: String(err?.message) });
105
+ }
68
106
  }
69
107
  };
108
+ function unreachable(baseUrl, timeoutMs, err) {
109
+ const cause = err?.name === "TimeoutError" || /abort/i.test(String(err?.message));
110
+ if (cause) return `No answer from ${baseUrl} within ${timeoutMs}ms. Is it reachable, and is the call this slow?`;
111
+ return `Could not reach ${baseUrl}. Is the server running, and is OYA_BASE_URL right?`;
112
+ }
70
113
  function requestInit(method, body, timeoutMs, headers) {
71
114
  const json = body === void 0 ? {} : { "Content-Type": "application/json" };
72
115
  const payload = body === void 0 ? void 0 : JSON.stringify(body);
@@ -93,13 +136,12 @@ function failure(call2, status, payload, baseUrl) {
93
136
  }
94
137
  var env = (name) => globalThis.process?.env?.[name];
95
138
  function createHttp(options) {
96
- const apiKey = options.apiKey || env("OYA_API_KEY");
97
- if (!apiKey) {
98
- throw new Error("No API key. Pass { apiKey } or set OYA_API_KEY \u2014 run `oya login` to get one.");
99
- }
100
- const baseUrl = (options.baseUrl || env("OYA_BASE_URL") || DEFAULT_BASE_URL).replace(/\/+$/, "");
139
+ const saved = savedConfig();
140
+ const apiKey = options.apiKey || env("OYA_API_KEY") || saved.apiKey;
141
+ if (!apiKey) throw new Error("No API key. Pass { apiKey }, set OYA_API_KEY, or run `npx @oya-ai/cli login`.");
142
+ const baseUrl = (options.baseUrl || env("OYA_BASE_URL") || saved.baseUrl || DEFAULT_BASE_URL).replace(/\/+$/, "");
101
143
  const fetchImpl = options.fetch || globalThis.fetch;
102
- if (!fetchImpl) throw new Error("No fetch available \u2014 pass { fetch } or use Node 18+.");
144
+ if (!fetchImpl) throw new Error("No fetch available, pass { fetch } or use Node 18+.");
103
145
  return new Http(baseUrl, apiKey, options.timeoutMs ?? DEFAULT_TIMEOUT_MS, fetchImpl.bind(globalThis));
104
146
  }
105
147
 
@@ -199,10 +241,8 @@ var Browser = class {
199
241
  constructor(http, info, autoCaptcha) {
200
242
  this.http = http;
201
243
  this.autoCaptcha = autoCaptcha;
202
- this.id = info.id;
203
- this.provider = info.provider;
204
- this.persona = info.persona;
205
- this.cdpUrl = info.cdpUrl;
244
+ ({ id: this.id, provider: this.provider, persona: this.persona, cdpUrl: this.cdpUrl } = info);
245
+ this.reused = info.reused === true;
206
246
  }
207
247
  http;
208
248
  autoCaptcha;
@@ -214,6 +254,8 @@ var Browser = class {
214
254
  persona;
215
255
  /** Point Playwright, Puppeteer or browser-use here. */
216
256
  cdpUrl;
257
+ /** True when `start()` handed over a browser that was already running, such as the desktop app. */
258
+ reused;
217
259
  /** Runs one browser command; a command that ran and failed throws. */
218
260
  async command(action, params = {}, timeoutMs) {
219
261
  const path = `/api/browsers/${this.id}/command`;
@@ -365,7 +407,7 @@ var Browser = class {
365
407
  /**
366
408
  * The SSE stream of JPEG frames, for embedding in your own UI. EventSource
367
409
  * cannot set headers, so the URL carries a connection ticket: single use,
368
- * 60 seconds. Mint one per viewer — the first connection spends it.
410
+ * 60 seconds. Mint one per viewer, the first connection spends it.
369
411
  */
370
412
  async liveStreamUrl() {
371
413
  const path = `/api/control/sessions/${encodeURIComponent(this.id)}/ticket`;
@@ -401,13 +443,14 @@ var Browser = class {
401
443
  * CDP session is handed back to its provider, a desktop browser disconnects.
402
444
  */
403
445
  stop() {
446
+ if (this.reused) return Promise.resolve({ id: this.id, ok: true, reused: true });
404
447
  return this.http.request("POST", `/api/browsers/${this.id}/stop`, {}, STOP_TIMEOUT_MS);
405
448
  }
406
449
  /** `await using browser = await oya.browser.start()` stops it however the block exits, errors included. */
407
450
  async [Symbol.asyncDispose]() {
408
451
  await this.stop();
409
452
  }
410
- /** @deprecated use stop() — close() only dropped the socket, and a cloud browser redialled. */
453
+ /** @deprecated use stop(), close() only dropped the socket, and a cloud browser redialled. */
411
454
  async close() {
412
455
  await this.stop();
413
456
  }
@@ -574,11 +617,11 @@ var identityCalls = (http) => ({
574
617
  /** One persona. */
575
618
  get: (id) => http().request("GET", `/api/personas/${id}`),
576
619
  /**
577
- * Create an identity. The device — platform, timezone, locale — is chosen
620
+ * Create an identity. The device, platform, timezone, locale, is chosen
578
621
  * here and fixed for its life; `preview()` shows what a choice produces.
579
622
  */
580
623
  create: (options = {}) => http().request("POST", "/api/personas", options),
581
- /** Name, concurrency cap and proxy hint. Never the device — clone for that. */
624
+ /** Name, concurrency cap and proxy hint. Never the device, clone for that. */
582
625
  update: (id, changes) => http().request("PUT", `/api/personas/${id}`, changes),
583
626
  /** A new persona of the same kind of device: same choices, fresh identity, empty jar. */
584
627
  clone: (id, options = {}) => http().request("POST", `/api/personas/${id}/clone`, options)
package/package.json CHANGED
@@ -1,9 +1,9 @@
1
1
  {
2
2
  "name": "@oya-ai/browser",
3
- "version": "1.0.103",
4
- "description": "Rotate thousands of browsers behind one API — personas, proxies, stealth, CAPTCHA and MFA.",
3
+ "version": "1.0.105",
4
+ "description": "Rotate thousands of browsers behind one API, personas, proxies, stealth, CAPTCHA and MFA.",
5
5
  "license": "MIT",
6
- "homepage": "https://browser.getoya.ai",
6
+ "homepage": "https://oyabrowser.com",
7
7
  "repository": {
8
8
  "type": "git",
9
9
  "url": "git+https://github.com/OyadotAI/oya-browser.git",