# TypeScript SDK

Source: https://phonebox.dev/docs/interfaces/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](/docs/interfaces/rest). 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](/docs/interfaces/cli).

## Install [#install]

```bash
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 [#quick-example]

```ts
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 [#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](/docs/api-reference/uploads#the-upload-object).                                                                                                                                                                                                                |
| `pb.uploads.get(id, { wait })`, `pb.uploads.list()`, `pb.uploads.delete(id)` | Read, list or delete [uploads](/docs/api-reference/uploads).                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                              |
| `pb.library.search(query, { cursor })`                                       | Searches [Phonebox's app library](/docs/api-reference/apps#search-the-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]

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 [#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 [#the-screen]

| Method                                      | What it does                                                                                                                             |
| ------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------- |
| `observe({ screenshot, maxWidth })`         | Returns the [observation](/docs/using-phones/observe). `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 [#actions]

`act(actions, { observe })` runs a batch of [actions](/docs/using-phones/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.

```ts
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](/docs/using-phones/actions#targets), such as `{ text: "Sign in" }` or `{ ref: 3, snapshot_id: "snp_4f2k7m3q6z3a" }`.

### Apps, files and the device [#apps-files-and-the-device]

| Method                                                                                                                    | What it does                                                                                                                                                                                          |
| ------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `apps.list()`, `apps.install(pkg)`, `apps.installs()`, `apps.uninstall(pkg)`                                              | Manage [apps](/docs/using-phones/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](/docs/using-phones/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](/docs/using-phones/device). `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](/docs/api-reference/device#get-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](/docs/api-reference/recordings). `video(id)` returns the MP4's bytes once the recording is ready.                                                                                 |
| `live({ expiresIn })`                                                                                                     | Creates a [live view link](/docs/using-phones/live-view) and returns `{ url, expires_at }`.                                                                                                           |

## Errors [#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 [#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.
