Phonebox / Docs
Interfaces

TypeScript SDK

Drive phones from Node.js with the phonebox package, its Phone methods, its errors, and how it retries and waits.

View as Markdown

The phonebox npm package is a typed client for the REST API. It runs on Node.js 20 or later, is published as an ES module, and depends only on zod. The same package contains the CLI.

Install

npm install https://phonebox.dev/downloads/phonebox-0.1.0.tgz

The package includes its type definitions. Import it with import, because it has no CommonJS build.

Quick example

import { Phonebox, PhoneboxError } from "phonebox";

const pb = new Phonebox(); // reads PHONEBOX_API_KEY
const key = `signup-test-${Date.now()}`; // a new key for each phone; reuse it only to repeat this create
const phone = await pb.phones.create({ name: "signup-test", idempotencyKey: key });
try {
  if (phone.status !== "ready") await phone.waitUntil("ready"); // create() returns the phone even when its wait was cut short
  console.log(await phone.look());
  await phone.tap({ text: "Sign in" });
  await phone.tap({ id: "email" });
  await phone.type("alex@example.com", { submit: true });
  await phone.waitFor({ text: "Welcome" }, { timeoutMs: 20_000 });
} catch (error) {
  if (error instanceof PhoneboxError) console.error(error.code, error.next);
  throw error;
} finally {
  await phone.park();
}

The client

new Phonebox(options) takes these options, all of them optional:

OptionMeaning
apiKeyThe API key. The default is the PHONEBOX_API_KEY environment variable.
baseUrlThe API origin. The default is PHONEBOX_URL, or https://phonebox.dev.
fetchA fetch function to use instead of the global one.
sleepHow the SDK waits between polls that don't long-poll, such as an install's. For tests.

Without a key, the constructor throws a PhoneboxError with the code missing_api_key.

MethodWhat it does
pb.phones.create(input)Creates a phone and, unless wait is false, waits until it is ready. input takes name, country, metadata, idleTimeout, maxDuration, wait, idempotencyKey and timeoutMs, which defaults to 10 minutes. Returns a Phone. Once the phone exists, only an error that names the phone in details.phone throws: the phone's own failure, or someone else parking or deleting it while create() waits. A wait that ends any other way, such as a timeout or a dropped connection, still returns the phone as it was last seen, so check its status.
pb.phones.list(query)Lists phones, newest first. query takes status, limit, cursor and metadata. Without status, it leaves out failed and deleted phones. Returns { data, nextCursor }, where data holds Phone objects.
pb.phones.get(id)Gets one phone as a Phone.
pb.account()Returns the account: balance, reserved credit, spending, limits and price.
pb.uploads.create(file, { onProgress, timeoutMs })Uploads your own APK and waits until it is ready. file is a path (Node), a Uint8Array or a Blob; a path streams from disk. The file goes straight to the upload's one-off URL, without your API key. An upload that ends otherwise throws its error, such as invalid_apk. Returns the upload.
pb.uploads.get(id, { wait }), pb.uploads.list(), pb.uploads.delete(id)Read, list or delete uploads.
pb.library.search(query, { cursor })Searches Phonebox's app library: the store and system apps apps.install(pkg) installs.

The SDK names options in camelCase, such as idleTimeout and maxDuration, and it sends them as the API's snake_case fields. Timers are in seconds and timeoutMs values in milliseconds. Actions and targets keep the API's own field names, such as timeout_ms and snapshot_id. So do the objects the SDK returns, except that pb.phones.list() names its cursor nextCursor.

A phone

A Phone holds the phone's latest state in phone.data, with phone.id and phone.status as shortcuts. Lifecycle methods refresh data, and screen and action methods leave it as it is. Call refresh() to read the phone again.

Lifecycle

MethodWhat it does
refresh()Reads the phone again.
waitUntil(target, timeoutMs)Waits until the phone is "ready" or "parked". The default timeout is 10 minutes. It throws at once when the phone can't get there without a new request, as described below. It only reads the phone, so wait this way for a phone that is still creating or starting, because start() renews a running phone's session.
start(options)Starts a parked phone, or renews a running one, and waits until it is ready. Options are idleTimeout, maxDuration, wait and timeoutMs, which defaults to 5 minutes.
park(options)Parks the phone and waits until it has stopped: up to about 50 seconds inside the park request, then up to 2 more minutes of polling. It waits only while the phone is parking, so a phone that the park leaves parked or unavailable returns at once. Pass { wait: false } to return at once in any case.
delete()Deletes the phone permanently. It needs an Admin key.
update({ name, metadata })Renames the phone or replaces its metadata.
heartbeat()Resets the idle timer.
reboot()Reboots the phone, and returns without waiting. Follow it with waitUntil("ready").
reset()Wipes the phone's apps, files, signed-in accounts and clipboard, keeping its ID, and returns without waiting: { phone, wiped }. Follow it with waitUntil("ready"). It needs an Admin key.
sessions({ limit, cursor })Lists the phone's sessions, with their cost.

The screen

MethodWhat it does
observe({ screenshot, maxWidth })Returns the observation. screenshot is "none", the default, "jpeg" or "png".
look()Returns the screen in the text form, as a string that starts with a line naming its snapshot, snapshot snp_…, as MCP's observe does.
screenshot({ format, maxWidth, quality })Returns the image's bytes as a Uint8Array. format is "png", the default, or "jpeg".

Actions

act(actions, { observe }) runs a batch of actions and returns the batch result. It doesn't throw when an action fails, so check completed and each result's ok. It throws only when the request as a whole fails.

const result = await phone.act(
  [
    { type: "tap", target: { text: "Search apps" } },
    { type: "type", text: "chrome" },
  ],
  { observe: "ui" },
);
if (result.completed < 2) console.log(result.results.at(-1)?.error);

These methods each send one action, and they throw a PhoneboxError when it fails:

MethodAction
tap(target, { long, double })tap, or long_press with long, or double_tap with double
type(text, { clear, submit })type
key(nameOrCode)key
back(), home(), recents(), notifications()back, home, recents, notifications
swipe(from, to, durationMs)swipe
scroll(direction, { target, amount })scroll
open(packageOrUrl)open_url when the argument has a scheme such as https:, otherwise open_app
waitFor(target, { gone, timeoutMs })wait_for

A target is an object in one of the target forms, such as { text: "Sign in" } or { ref: 3, snapshot_id: "snp_4f2k7m3q6z3a" }.

Apps, files and the device

MethodWhat it does
apps.list(), apps.install(pkg), apps.installs(), apps.uninstall(pkg)Manage apps. install(pkg) starts an install from Phonebox's app library and answers at once.
apps.install({ upload, replace })Installs an upload and waits until the phone lists the app, up to 10 minutes. A failure throws install_failed with the next step. replace: true uninstalls the app first, which deletes its data.
installApk(file, { replace, onProgress })Installs your own APK in one call: pb.uploads.create(file), then apps.install({ upload }).
files.list(dir), files.upload(path, bytes), files.download(path), files.remove(path)Manage files. upload takes a Uint8Array, and download returns one.
clipboard.get(), clipboard.set(text)Read or set the clipboard.
setLocation(lat, lng, { timezoneFromLocation }), resetLocation(), setLocale(locale), setTimezone(timezone)Change the device settings. setLocation also sets the timezone found there, unless timezoneFromLocation is false.
getLocation(), getLocale(), getTimezone(), info()Read the location, locale and timezone, and the phone's device info: brand, model, Android version, screen and carrier.
recordings.start({ name }), recordings.list(), recordings.stop(id), recordings.video(id), recordings.delete(id)Record the screen. video(id) returns the MP4's bytes once the recording is ready.
live({ expiresIn })Creates a live view link and returns { url, expires_at }.

Errors

An error that the API reports is a PhoneboxError, carrying the API's error envelope:

PropertyMeaning
statusThe HTTP status, or 0 when no request was made.
codeThe error code, such as phone_not_running.
messageA sentence for people.
retryableWhether the same call may succeed later.
nextThe next step, when there is one.
requestIdThe request's ID, from its X-Request-Id header. A single-action method's error carries the ID of the batch request that ran the action.
detailsFacts about the error, such as candidates for a target error.

When a single-action method such as tap() fails, the error carries the action's code with that code's usual HTTP status, so a phone_unavailable has the status 503 and a target_not_found 422.

An answer without the envelope, such as a gateway's 504, becomes a PhoneboxError with the code internal_error, that status and retryable set to false. Only an answer that carries an X-Request-Id header is read as Phonebox's envelope, so a proxy's JSON error, whatever it says, is reported this way too. A network error or a timeout is thrown as the error that fetch gave, such as a TypeError or a TimeoutError. After either on a write, such as act() or tap(), the write may have happened: observe before you send it again.

Retries and waiting

  • A GET that fails with a network error, a timeout, or the status 429, 502, 503 or 504 is sent up to twice more, after a quarter and then half a second.
  • A POST, PUT, PATCH or DELETE is sent only once, so the SDK never repeats an action or a create. Pass idempotencyKey to create() so that you can repeat a create yourself safely.
  • create(), start() and park() send their request, which itself waits up to about 50 seconds, and then poll GET /v1/phones/{id}?wait=… in requests of about 50 seconds each, until the phone gets there or timeoutMs passes after that first answer. When the time runs out, start(), park() and waitUntil() throw a PhoneboxError with the code wait_timeout and the status 422, which is retryable and names the phone in details.phone. create() returns the phone instead, as it was last seen.
  • A wait stops at once when the phone lands where it can't get there without a new request. A phone that failed, or was deleted, makes it throw with the failure's code, retryable set to false, and a next that says to create a new phone with a new idempotency key. A start that fails and leaves the phone parked again throws with the failure's code too, but keeps the code's usual retryable, because the same phone can start again. A phone that someone else parked makes a wait for ready throw phone_not_running, whose next is the start request.
  • A poll that answers at once without reaching the status waits before the next one: 1 second, doubling to 10.
  • Each HTTP request gives up after 130 seconds, except a batch of actions, from act() or a single-action method, which waits up to 310 seconds: a little longer than the 300 seconds a request may run at the platform, so the batch's own answer arrives first.

On this page