RoxyBrowser Script Development
TIP
RoxyBrowser automation scripts are reusable Playwright TypeScript scripts. They run in a controlled sandbox provided by RoxyBrowser and connect to the assigned browser profile to perform page operations, data collection, content publishing, account maintenance, and other automated tasks.
What Is A Script
A RoxyBrowser script is not a normal local Node.js program. Each script is a self-describing TypeScript file. Before execution, RoxyBrowser parses its script metadata, parameter schema, and entry point, then runs it inside a controlled VM sandbox.
A standard script includes the following parts:
| Part | Purpose |
|---|---|
defineMetadata({...}) | Declares the script name, description, version, runtime directory name, and other metadata |
defineParams({...}) | Declares the JSON Schema for runtime parameters |
export async function main(params) | Script entry point called by the sandbox |
| Playwright connection logic | Connects to the RoxyBrowser-provided browser through process.env.BROWSER_URL |
| Optional permission file | Allows external file reads or adds non-default hosts for fetch |
Basic Script Template
import { chromium, firefox } from 'playwright'
import { defineMetadata, defineParams } from '@roxybrowser/sandbox'
import { sleep } from '@roxybrowser/sandbox/utils'
interface ScriptParams {
keyword: string
}
defineMetadata({
name: 'Search keyword',
description: 'Open the current browser profile and search for a keyword',
version: '1.0.0',
slug: 'search-keyword',
})
defineParams({
type: 'object',
properties: {
keyword: {
type: 'string',
description: 'Keyword to search for',
},
},
required: ['keyword'],
})
export async function main(params: ScriptParams): Promise<void> {
const browserUrl = process.env.BROWSER_URL ?? ''
const browserType = process.env.BROWSER_TYPE ?? 'chromium'
const browser = browserType === 'firefox'
? await firefox.connect(browserUrl)
: await chromium.connect(browserUrl)
const context = browser.contexts()[0] ?? await browser.newContext()
const page = context.pages()[0] ?? await context.newPage()
await page.goto('https://www.google.com/search?q=' + encodeURIComponent(params.keyword))
await sleep(1000, 2000)
await browser.close()
}When writing scripts, note the following:
defineMetadataanddefineParamsmust be top-level static object-literal calls. Do not use variables, spread syntax, helper calls, or computed values inside them.- The module must export
main. - Do not call
mainmanually. The runner calls it automatically. - Runtime values should be passed through
main(params). Avoid hard-coding values that change per run, such as keywords, URLs, counts, probabilities, and ranges. - A script with no inputs still needs
defineParams({ type: 'object', properties: {} }).
Sandbox Runtime
RoxyBrowser scripts run inside a vm.SourceTextModule sandbox. It is a controlled subset of Node.js designed to let scripts control the browser reliably while limiting access to the host system, network, and environment variables.
Injected Globals
The following global capabilities are available without imports:
| Global | Description |
|---|---|
console | Logs are forwarded to the run log; console.debug only emits in debug mode |
process | A controlled process subset that exposes only safe methods and allowlisted environment variables |
fetch | Available; common platform domains are allowed by default, and other hosts require a permission file |
| Timers | setTimeout, clearTimeout, setInterval, clearInterval |
| Standard built-ins | Buffer, URL, URLSearchParams, TextEncoder, TextDecoder, Promise, JSON, Date, Map, Set, and more |
XMLHttpRequest is not available in the sandbox.
process Subset
process.cwd() returns the real work directory for the current script:
workdir/{slug}/This directory is not the project root or the system root. Script-generated files, Table data, and KV data should be stored here whenever possible.
Only these environment variables are available:
| Environment variable | Description |
|---|---|
BROWSER_URL | Browser WebSocket endpoint used by chromium.connect() or firefox.connect() |
BROWSER_TYPE | Browser type, usually chromium, sometimes firefox |
SANDBOX_DEBUG | 1 in debug mode, 0 in release mode |
Other host environment variables are not exposed and evaluate to undefined. Do not enumerate or depend on host environment variables.
Allowed Imports
The sandbox enforces an import allowlist. The following modules are allowed:
| Module | Purpose |
|---|---|
playwright | Connect to and control the RoxyBrowser-provided browser |
fs, node:fs, fs/promises, node:fs/promises | File access, constrained by the permission model |
path, node:path | Path handling |
url, node:url | URL handling |
crypto, node:crypto | Hashing, random values, and basic crypto utilities |
buffer, node:buffer | Buffer handling |
stream, node:stream | Stream handling |
@roxybrowser/sandbox | Script self-description macros |
@roxybrowser/sandbox/* | Built-in RoxyBrowser sandbox capability packages |
Imports outside the allowlist fail at runtime:
Module not allowedCommon blocked modules include child_process, worker_threads, net, http, https, os, vm, and dgram. Page traffic should be produced through Playwright. If the script itself needs to make an HTTP request, use the permission-controlled fetch.
Built-In Sandbox Packages
RoxyBrowser provides a set of @roxybrowser/sandbox/* packages. Prefer these packages over reimplementing common capabilities inside scripts.
@roxybrowser/sandbox
The root package provides script self-description macros:
import { defineMetadata, defineParams } from '@roxybrowser/sandbox'| API | Description |
|---|---|
defineMetadata(metadata) | Declares the script name, description, version, slug, default browser profile, and related metadata |
defineParams(schema) | Declares the JSON Schema for main(params) |
name, description, and parameter description values support localized objects:
defineMetadata({
name: {
zh: '采集商品价格',
us: 'Scrape product prices',
ru: 'Сбор цен товаров',
},
description: {
zh: '从商品列表页采集标题和价格',
us: 'Scrape titles and prices from a product listing page',
ru: 'Собирает названия и цены со страницы списка товаров',
},
})@roxybrowser/sandbox/utils
The utilities package provides pure helper functions. It does not access the network, does not use secrets, and does not persist data by itself.
| API | Description |
|---|---|
sleep(min, max) | Waits for a random duration in [min, max] milliseconds, useful for natural pacing |
randomBetween(min, max) | Returns a random integer in the given range |
pick(arr) | Randomly selects one item from an array |
maybe(probability) | Returns true with the given probability, for example maybe(0.7) means 70% |
chunk(arr, size) | Splits an array into fixed-size batches |
retry(fn, opts) | Retries unstable operations with backoff |
Example:
import { maybe, retry, sleep } from '@roxybrowser/sandbox/utils'
if (maybe(0.7)) {
await page.getByRole('button', { name: 'Like' }).click()
}
await retry(
() => page.locator('[data-testid="result"]').waitFor({ state: 'visible' }),
{ attempts: 3, baseDelay: 500 },
)
await sleep(1000, 3000)@roxybrowser/sandbox/human
The human package provides explicit human-like mouse, keyboard, and scroll operations.
import { createHuman } from '@roxybrowser/sandbox/human'
const human = createHuman(page)
await human.click({
target: page.getByRole('button', { name: 'Submit' }),
motion: {
duration: 600,
curve: 'ease-in-out',
path: { type: 'bezier', curvature: 40 },
},
settleDuration: 300,
holdDuration: 80,
})createHuman(page) tracks its own pointer position. After creating a human actor, keep the same flow on human.move, human.hover, human.click, human.type, and human.scroll when possible. Mixing it with page.mouse or Locator click/hover can make the tracked pointer position inaccurate.
@roxybrowser/sandbox/2fa
The 2fa package handles TOTP two-factor authentication codes.
import { parse2FA, totp } from '@roxybrowser/sandbox/2fa'
const code = totp(params.twoFactorSecret)
const detail = parse2FA(params.twoFactorSecret)
console.log(`2FA code expires in ${detail.secondsRemaining}s`)| API | Description |
|---|---|
totp(input, options?) | Returns the current verification code |
parse2FA(input, options?) | Returns the code, remaining seconds, expiry time, digits, algorithm, and related details |
@roxybrowser/sandbox/llm
The llm package calls a model through the host. It is useful for text generation, classification, extraction, and structured decisions inside a script. It may be slower and may incur model cost, so use it only when the business flow needs it.
import { json } from '@roxybrowser/sandbox/llm'
const result = await json<{ price: number }>(
`Extract the product price from this text: ${text}`,
{
type: 'object',
properties: {
price: { type: 'number' },
},
required: ['price'],
},
{ temperature: 0, maxTokens: 200 },
)| API | Description |
|---|---|
ask(prompt, opts?) | Returns plain model-generated text |
json(prompt, schema, opts?) | Returns a structured object matching the JSON Schema |
@roxybrowser/sandbox/table
Table is CSV row storage. It is suitable for scraped results, execution records, and exported data.
import { Table } from '@roxybrowser/sandbox/table'
const records = new Table('product-records')
await records.append({
title,
price,
url: page.url(),
capturedAt: Date.now(),
})
const rows = await records.all()One new Table(name) instance maps to one CSV file in the script work directory:
<name>.csvAll methods return Promises and must be awaited:
| API | Description |
|---|---|
append(record | record[]) | Appends one or more rows |
all() | Reads all rows |
find(predicate) | Finds the first matching row |
clear() | Clears the table |
@roxybrowser/sandbox/kv
KV is JSON key-value storage. It is suitable for cross-run state, such as processed IDs, last execution time, and pagination cursors.
import { KV } from '@roxybrowser/sandbox/kv'
const state = new KV('crawler-state')
if (await state.has(productId)) {
console.log('skip processed product')
return
}
await state.set(productId, {
processedAt: Date.now(),
url: page.url(),
})One new KV(name) instance maps to one file in the script work directory:
<name>.kv.json| API | Description |
|---|---|
get(key) | Reads a key; returns undefined when it does not exist |
set(key, value) | Writes a JSON-serializable value |
has(key) | Checks whether a key exists |
delete(key) | Deletes a key |
keys() | Reads all keys |
all() | Reads the full snapshot |
clear() | Clears the namespace |
Permission Model
The sandbox permission model follows the principle of least privilege:
- The work directory
workdir/{slug}/is readable and writable by default. - External files are not readable by default and must be declared in
fs.read. - External paths are not writable, even if a permission file declares them.
fetchallows common platform domains by default. Hosts outside the default list must be declared infetch.allow.- Imports, environment variables, and low-level network modules are allowlisted.
Filesystem Permissions
Scripts can import native fs / node:fs / fs/promises / node:fs/promises. These module methods are not rewritten by RoxyBrowser. The actual access boundary is enforced through Node permission flags.
The recommended pattern is to write script outputs into the current work directory:
import { writeFile } from 'node:fs/promises'
import path from 'node:path'
const outputPath = path.join(process.cwd(), 'result.json')
await writeFile(outputPath, JSON.stringify({ ok: true }, null, 2), 'utf8')If the script needs to read an external input file such as /Users/me/input/accounts.csv, declare the readable path in the permission file:
{
"version": 1,
"fs": {
"read": [
"/Users/me/input/"
]
}
}Notes:
- Each value in
fs.readis passed to Node as a separate--allow-fs-read=<value>argument. - Declaring a directory is usually more reusable than declaring one temporary file.
- External writes are not supported. Write files to
process.cwd(), or useTableandKV.
fetch Permissions
The script runtime provides fetch and allows a set of common platform domains by default. The default list covers common cross-border ecommerce, social media, and content platforms, such as Alibaba, AliExpress, Amazon, eBay, Etsy, Lazada, Mercado Libre, Rakuten, Shopee, Shopify, Walmart, Temu, TikTok Shop, Meta, TikTok, YouTube, X/Twitter, Reddit, LinkedIn, Pinterest, Telegram, and Discord.
Domains in the default list match both the root domain and subdomains. For example, if youtube.com is allowed by default, both youtube.com and www.youtube.com are allowed.
If a script needs to access a host outside the default list, add it through the permission file. The permission file extends the default list; it does not replace it:
{
"version": 1,
"fetch": {
"allow": [
"api.example.com",
"*.googleapis.com"
]
}
}Matching rules:
api.example.comallows only that exact host.*.googleapis.comallows subdomains such assheets.googleapis.comanddrive.googleapis.com.- A wildcard only matches subdomains, not the apex domain itself.
*.example.comdoes not includeexample.com. - Only HTTP(S) requests are supported.
- Automatic redirects are disabled. If an endpoint returns 3xx, the script should inspect
location, confirm the destination host is also allowed, and then request it explicitly.
If the data already flows through the page, prefer listening to Playwright responses instead of making a separate fetch call:
page.on('response', async (response) => {
if (response.url().includes('/api/products')) {
const data = await response.json()
console.log('products', data.length)
}
})Browser page traffic is initiated by the browser and does not require fetch.allow.
Permission Denial Signal
When a script crosses a permission boundary, the common error is:
Access to this API has been restricted.When you see this error, identify the failing operation first:
| Failed operation | What to check |
|---|---|
| File read failed | Check whether the path is under process.cwd() or covered by fs.read |
| File write failed | Check whether the destination is outside the work directory; external writes cannot be authorized |
fetch failed | Check whether the request host and each redirect destination are in the default allowlist or fetch.allow |
Do not treat a permission denial as a page logic bug. Fix the path or permission file first, then rerun.
Output, Persistence, And Debugging
Different output types should go to different places:
| Type | Recommended method |
|---|---|
| Progress | console.log / console.info |
| Debug details | console.debug |
| Failure reason | console.error, with enough context to diagnose the issue |
| Scraped results | Table |
| Cross-run state | KV |
| Arbitrary file outputs | Write to process.cwd() |
| Page screenshots | page.screenshot({ path: 'name.png' }), keeping the path relative |
The path in page.screenshot({ path: 'result.png' }) is handled by Playwright through the host and saved under the browser artifact area in the script work directory. Keep screenshot paths relative. Do not build host-only Playwright directories manually.
Common Errors
| Error | Cause | How to fix |
|---|---|---|
Module not allowed | Imported a module outside the allowlist | Use an allowed module or a sandbox package |
Access to this API has been restricted. | File or fetch permission is missing | Check fs.read, the default fetch allowlist, fetch.allow, or the write path |
defineMetadata cannot be parsed | Metadata is not a top-level static object literal | Remove dynamic variables, spread syntax, and helper calls from macro arguments |
defineParams cannot be parsed | The parameter schema is not a top-level static object literal | Use a complete static JSON Schema |
main not found | The script does not export an entry point | Use export async function main(...) |
| Clicked the wrong element | The locator is too broad or .first() matched the wrong instance | Scope to a unique container before locating the control |
| Script times out intermittently | The page has not rendered yet or lazy loading is not complete | Wait for a concrete element or load-stability signal |
