Skip to content

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:

PartPurpose
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 logicConnects to the RoxyBrowser-provided browser through process.env.BROWSER_URL
Optional permission fileAllows external file reads or adds non-default hosts for fetch

Basic Script Template

ts
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:

  • defineMetadata and defineParams must 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 main manually. 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:

GlobalDescription
consoleLogs are forwarded to the run log; console.debug only emits in debug mode
processA controlled process subset that exposes only safe methods and allowlisted environment variables
fetchAvailable; common platform domains are allowed by default, and other hosts require a permission file
TimerssetTimeout, clearTimeout, setInterval, clearInterval
Standard built-insBuffer, 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:

text
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 variableDescription
BROWSER_URLBrowser WebSocket endpoint used by chromium.connect() or firefox.connect()
BROWSER_TYPEBrowser type, usually chromium, sometimes firefox
SANDBOX_DEBUG1 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:

ModulePurpose
playwrightConnect to and control the RoxyBrowser-provided browser
fs, node:fs, fs/promises, node:fs/promisesFile access, constrained by the permission model
path, node:pathPath handling
url, node:urlURL handling
crypto, node:cryptoHashing, random values, and basic crypto utilities
buffer, node:bufferBuffer handling
stream, node:streamStream handling
@roxybrowser/sandboxScript self-description macros
@roxybrowser/sandbox/*Built-in RoxyBrowser sandbox capability packages

Imports outside the allowlist fail at runtime:

text
Module not allowed

Common 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:

ts
import { defineMetadata, defineParams } from '@roxybrowser/sandbox'
APIDescription
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:

ts
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.

APIDescription
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:

ts
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.

ts
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.

ts
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`)
APIDescription
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.

ts
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 },
)
APIDescription
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.

ts
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:

text
<name>.csv

All methods return Promises and must be awaited:

APIDescription
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.

ts
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:

text
<name>.kv.json
APIDescription
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.
  • fetch allows common platform domains by default. Hosts outside the default list must be declared in fetch.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:

ts
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:

json
{
  "version": 1,
  "fs": {
    "read": [
      "/Users/me/input/"
    ]
  }
}

Notes:

  • Each value in fs.read is 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 use Table and KV.

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:

json
{
  "version": 1,
  "fetch": {
    "allow": [
      "api.example.com",
      "*.googleapis.com"
    ]
  }
}

Matching rules:

  • api.example.com allows only that exact host.
  • *.googleapis.com allows subdomains such as sheets.googleapis.com and drive.googleapis.com.
  • A wildcard only matches subdomains, not the apex domain itself. *.example.com does not include example.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:

ts
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:

text
Access to this API has been restricted.

When you see this error, identify the failing operation first:

Failed operationWhat to check
File read failedCheck whether the path is under process.cwd() or covered by fs.read
File write failedCheck whether the destination is outside the work directory; external writes cannot be authorized
fetch failedCheck 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:

TypeRecommended method
Progressconsole.log / console.info
Debug detailsconsole.debug
Failure reasonconsole.error, with enough context to diagnose the issue
Scraped resultsTable
Cross-run stateKV
Arbitrary file outputsWrite to process.cwd()
Page screenshotspage.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

ErrorCauseHow to fix
Module not allowedImported a module outside the allowlistUse an allowed module or a sandbox package
Access to this API has been restricted.File or fetch permission is missingCheck fs.read, the default fetch allowlist, fetch.allow, or the write path
defineMetadata cannot be parsedMetadata is not a top-level static object literalRemove dynamic variables, spread syntax, and helper calls from macro arguments
defineParams cannot be parsedThe parameter schema is not a top-level static object literalUse a complete static JSON Schema
main not foundThe script does not export an entry pointUse export async function main(...)
Clicked the wrong elementThe locator is too broad or .first() matched the wrong instanceScope to a unique container before locating the control
Script times out intermittentlyThe page has not rendered yet or lazy loading is not completeWait for a concrete element or load-stability signal