TypeScript SDK
Drive phones from Node.js with the phonebox package, its Phone methods, its errors, and how it retries and waits.
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.tgzThe 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:
| Option | Meaning |
|---|---|
apiKey | The API key. The default is the PHONEBOX_API_KEY environment variable. |
baseUrl | The API origin. The default is PHONEBOX_URL, or https://phonebox.dev. |
fetch | A fetch function to use instead of the global one. |
sleep | How 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.
| Method | What 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
| Method | What 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
| Method | What 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:
| Method | Action |
|---|---|
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
| Method | What 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:
| Property | Meaning |
|---|---|
status | The HTTP status, or 0 when no request was made. |
code | The error code, such as phone_not_running. |
message | A sentence for people. |
retryable | Whether the same call may succeed later. |
next | The next step, when there is one. |
requestId | The 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. |
details | Facts 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
idempotencyKeytocreate()so that you can repeat a create yourself safely. create(),start()andpark()send their request, which itself waits up to about 50 seconds, and then pollGET /v1/phones/{id}?wait=…in requests of about 50 seconds each, until the phone gets there ortimeoutMspasses after that first answer. When the time runs out,start(),park()andwaitUntil()throw aPhoneboxErrorwith the codewait_timeoutand the status 422, which is retryable and names the phone indetails.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,
retryableset tofalse, and anextthat 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 usualretryable, because the same phone can start again. A phone that someone else parked makes a wait forreadythrowphone_not_running, whosenextis 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.