# TypeScript SDK

The TypeScript SDK wraps the [REST API](/api) and the [WebSocket](/websocket) behind typed
resources. It is async only, ESM only, and has no runtime dependencies.

## Install

```bash
npm install @0bull/sdk
```

The package is [`@0bull/sdk`](https://www.npmjs.com/package/@0bull/sdk) on npm and needs Node
22 or newer. Source and changelog:
[github.com/0bull/0bull-typescript-sdk](https://github.com/0bull/0bull-typescript-sdk).

This page documents **0.2.0**.

Upgrading from 0.1.0: a phone-controller session phone is keyed `slot`, not `id`.

Methods are camelCase. Fields sent to and returned by the API keep their snake_case names, so
the types match the [API reference](/api) one for one. Every duration is in milliseconds.

## Authenticate

Create a token at
[0bull.net/settings/api-tokens](https://0bull.net/settings/api-tokens), scoped to the
abilities your code needs. The [ability table](/introduction#token-abilities) lists what each
one grants.

Pass the token, or set `ZEROBULL_API_TOKEN` and pass nothing:

```ts
import { ZeroBull } from "@0bull/sdk";

const client = new ZeroBull({ apiToken: "..." });
// or, with ZEROBULL_API_TOKEN set:
const client = new ZeroBull();
```

A call made with a token that lacks the ability throws `PermissionDeniedError`.

| Option | Default | Environment |
|---|---|---|
| `apiToken` | required | `ZEROBULL_API_TOKEN` |
| `baseURL` | `https://0bull.net/api` | `ZEROBULL_BASE_URL` |
| `timeout` | 30000 | |
| `maxRetries` | 2 | |
| `fetch` | the global `fetch` | |

## First call

```ts
import { ZeroBull } from "@0bull/sdk";

const client = new ZeroBull();

for (const phone of await client.phones.list()) {
  console.log(phone.slot, phone.name, phone.can_control);
}
```

`phones.list()` resolves to `Phone` objects with `slot`, `name`, `video_live`,
`input_present`, `can_control`, `model` and `os_version`. The `slot` is the UUID every other
phone method takes. The `name` is a label, and passing it where a slot is wanted matches no
phone.

## Phones

Coordinates are fractions of the screen, 0 to 1. `snapshot` and `ocr` take an optional
`width` between 120 and 2000.

```ts
const jpeg: Uint8Array = await client.phones.snapshot(slot, { width: 600 });
const text: string = await client.phones.ocr(slot);

await client.phones.tap(slot, { fx: 0.5, fy: 0.9 });
await client.phones.swipe(slot, { fx1: 0.5, fy1: 0.8, fx2: 0.5, fy2: 0.2, steps: 30 });
await client.phones.type(slot, "hello");
await client.phones.hotkey(slot, "enter");
```

Input runs on the phone before the promise resolves, so there is nothing to poll.

`hotkey` accepts `home`, `app_switcher`, `control_center`, `notifications`, `back`,
`run_shortcut`, `enter`, `backspace`, `copy`, `cut`, `paste` and `select_all`. The union is
exported as `Hotkey`, and the list as `HOTKEYS`. An unknown key throws `TypeError` before any
request is sent.

## Runs

Commands, macros and agent tasks each queue a `Run` and resolve with it right away:

```ts
let run = await client.phones.runCommand(slot, "brightness", { level: 0.5 });
run = await client.phones.runMacro(slot, { workflow: "post-to-story", params: { caption: "hi" } });
run = await client.phones.runAgent(slot, "Open Settings and turn on Wi-Fi");
```

`runCommand` ops are `clipboard_set` (needs `text`), `clipboard_get`, `open_url` (needs
`url`), `reboot`, `clear_photos`, `get_ip`, `brightness` (needs `level`, 0 to 1), and `wifi`,
`airplane`, `cellular` and `flashlight` (each needs `on`).

`runMacro` takes exactly one of `workflow` (with optional scalar `params`) or `steps`, up to
200 steps. `runAgent` takes a task of 1 to 2000 characters.

### Waiting on a run

The run comes back before the phone has done the work. Poll it with `runs.wait()`, which
resolves once the status is `succeeded`, `failed` or `cancelled`:

```ts
const queued = await client.phones.runCommand(slot, "get_ip");
const run = await client.runs.wait(queued, { timeout: 300_000, interval: 2_000 });

if (run.status === "succeeded") {
  console.log(run.result?.value);
} else {
  console.log(run.error);
}
```

`get_ip` and `clipboard_get` put what the phone reported in `result.value`. Every other
command finishes with `result` set to `null`, so check `status` rather than the result.
`TERMINAL_RUN_STATUSES` is exported if you need to test a run you already hold.

`wait()` throws `WaitTimeoutError` once `timeout` passes. Defaults are 300000ms with a 2000ms
interval.

Read the history with `runs.list()`, which resolves to a `Page`. `for await` walks that page
and every page after it:

```ts
for await (const run of await client.runs.list(slot)) {
  console.log(run.id, run.kind, run.status);
}
```

Or page by hand with `nextPage()`, which returns `null` on the last page.
`page.currentPage`, `page.lastPage`, `page.perPage` and `page.total` describe where you are.

## Accounts

```ts
const account = await client.accounts.create({ platform: "tiktok", handle: "@me", slot });
await client.accounts.update(account.id, { notes: "test" });
await client.accounts.delete(account.id);

for await (const account of await client.accounts.list({ platform: "tiktok" })) {
  console.log(account.id, account.handle);
}
```

`platform` defaults to `tiktok`. TikTok accounts need a `slot`, YouTube accounts need a
`google_email`, and passing `google_email` on any other platform throws `TypeError`. `update`
changes only the fields you pass, and `null` clears one.

## Submissions and uploads

```ts
const submission = await client.submissions.create({
  account_id: account.id,
  video: "clip.mp4",
  caption: "hello from 0bull",
});
```

Pass exactly one of `video` (a local path, a `Uint8Array`, or a `Blob`), `video_url`, or
`upload_id`. On YouTube the caption is the Short's title, so it is required there and capped
at 100 characters instead of 2200.

To post the same video to several accounts, upload it once and reuse the id:

```ts
const uploadId = await client.uploads.upload("clip.mp4");
await client.submissions.create({ account_id: account.id, upload_id: uploadId, caption: "hi" });
```

Publishing happens on a real phone after the call returns, so wait for it:

```ts
const finished = await client.submissions.wait(submission, { timeout: 900_000, interval: 5_000 });
console.log(finished.status, finished.failure);
```

`wait()` resolves once the status is `published`, `drafted`, `failed` or `cancelled`, and
throws `WaitTimeoutError` otherwise. `cancel(id)` stops one that is still running, and
`delete(id)` removes it and its stored video.

## Billing

```ts
const summary = await client.billing.summary();
const rental = await client.billing.startRental({ accept_terms: true, phones: 5, country: "US" });
const change = await client.billing.requestPhoneCount({ accept_terms: true, add: 2 });
const request = await client.billing.getRequest(change.request_id);
```

`startRental` returns a `checkout_url` for the user to open. `requestPhoneCount` applies
straight away when it fits your standing allowance, otherwise it returns an `approval_url`
and stays pending until someone acts on it. Poll `getRequest` for the answer.

`accept_terms: true` confirms you showed the user the [terms](https://0bull.net/terms) and
[privacy policy](https://0bull.net/privacy), including that a rental renews monthly, before
the charge. `false` throws `TypeError`.

## Events over WebSocket

`client.socket()` gives you the same resource methods over one connection that also pushes
status events, so you can stop polling. The socket starts unconnected, so call `connect()`:

```ts
import { TERMINAL_RUN_STATUSES, ZeroBull } from "@0bull/sdk";

const client = new ZeroBull();
const socket = client.socket();
await socket.connect();

try {
  const run = await socket.phones.runMacro(slot, { workflow: "post-to-story" });
  for await (const event of socket.events()) {
    if (event.type === "run" && event.run.id === run.id && TERMINAL_RUN_STATUSES.has(event.run.status)) {
      console.log(event.run.status);
      break;
    }
  }
} finally {
  socket.close();
}
```

`events()` yields a union discriminated on `type`: `{ type: "run", run }`,
`{ type: "submission", submission }` and `{ type: "billing_request", request }`.

The socket reconnects on its own after an unexpected drop; calls that were in flight throw
`SocketClosedError` rather than being resent. `user` and `session` are REST only. A local
`video` works over the socket too: the SDK uploads it through a signed URL first and sends the
resulting `upload_id`. Use `socket.call(fun, data)` for a [fun](/websocket) the SDK does not
model yet.

On TypeScript 5.2 or newer with `lib: "esnext"`, or Node 24, `await using socket =
client.socket()` closes the socket when the scope exits and replaces the `try` block above.

## Errors

Everything the SDK throws on purpose subclasses `ZeroBullError`.

| Condition | Exception |
|---|---|
| 400 | `BadRequestError` |
| 401 | `AuthenticationError` |
| 403 | `PermissionDeniedError` |
| 404 | `NotFoundError` |
| 409 | `ConflictError` |
| 422 | `ValidationError`, with `.errors` |
| 429 | `RateLimitError`, with `.retryAfter` |
| 502, 503 | `UnavailableError` |
| Any other 5xx | `InternalServerError` |
| Any other status | `APIStatusError` |
| Network failure | `APIConnectionError` |
| Timeout | `APITimeoutError` |
| Socket closed mid-call | `SocketClosedError` |
| `wait()` timed out | `WaitTimeoutError` |

```ts
import { APIStatusError, ValidationError } from "@0bull/sdk";

try {
  await client.submissions.create({
    account_id: account.id,
    video_url: "https://example.com/missing.mp4",
    caption: "hi",
  });
} catch (error) {
  if (error instanceof ValidationError) {
    console.log(error.errors);
  } else if (error instanceof APIStatusError) {
    console.log(error.status, error.message);
  } else {
    throw error;
  }
}
```

Arguments the SDK can check itself throw `TypeError` before any request goes out. Only a
request that reached the server can throw `ValidationError`.

## Rate limits

The API allows 250 requests a minute, and 60 a minute for `ocr`. A `429` is retried
automatically up to `maxRetries`, honoring `Retry-After`, before `RateLimitError` is thrown.
Nothing else is retried.
