# WebSocket

The WebSocket gives you one persistent connection that can drive phones, manage accounts,
submissions and billing, and receive push events, instead of polling REST.

## Connecting

**1. Get a socket URL.** Call the REST endpoint with your API token:

```bash
curl https://0bull.net/api/v1/phone-controller \
  -H "Authorization: Bearer <your-token>" \
  -H "Accept: application/json"
```

The response includes a `socket_url`:

```json
{
  "phones": [ ... ],
  "socket_url": "wss://0bull.net/farm-ws?t=<short-lived-token>",
  "ice_servers": [ ... ],
  "farm_online": true
}
```

**2. Connect within the token's TTL.** `socket_url` embeds a short-lived token (10 minutes
by default) scoped to your account, the phones you can view or control, and your API
token's abilities. Connect before it expires. After that, call `/api/v1/phone-controller`
again for a fresh one.

`phones` lists what your account can currently see, in the same shape as `GET /v1/phones` in
the [REST API reference](/api). `ice_servers` is only needed if you also handle live video
from a phone. Skip it if you only want the funs below.

## Frame format

Every request you send is a JSON frame:

```json
{ "fun": "/app/phones/list", "msgid": "1", "data": {} }
```

- **`fun`**: which capability to call. Every fun on this page starts with `/app/`.
- **`msgid`**: any string or number you choose. The reply echoes it back, so you can match
  replies to requests on a connection carrying several in flight at once.
- **`data`**: the fun's parameters. Omit it (or send `{}`) for a fun that takes none.

## Reply envelope

A successful call replies with the REST status code and the same JSON body the equivalent
REST endpoint returns:

```json
{ "fun": "/app/phones/list", "msgid": "1", "status": 200, "data": [ ... ] }
```

A failed call replies with an error status, a message, and field errors when the failure was
a validation failure:

```json
{
  "fun": "/app/phones/input",
  "msgid": "2",
  "status": 422,
  "message": "The slot field is required.",
  "errors": { "slot": ["The slot field is required."] }
}
```

Status codes follow the same rules as the REST API: `422` for validation, `403` for a
missing ability or a phone you cannot view or control, `404` for a missing record, `409` for
a conflict such as cancelling a submission that already finished, `502` when a phone is
unreachable, `503` when a capability is not ready yet.

`401` and `429` never appear in a fun reply. They belong to the REST call that mints the
socket URL: an expired or missing API token, or too many requests, both stop you before you
ever connect.

## Phones

All phone funs need a `slot` (the phone's UUID from `/app/phones/list`) in `data`, except
`/app/phones/list` itself. `phones:control` funs additionally require the token to hold a
**control** grant on that slot; `phones:read` funs need only a **view** grant.

| Fun | Ability | Data fields | Returns |
|---|---|---|---|
| `/app/phones/list` | `phones:read` | *(none)* | Array of `{slot, name, video_live, input_present, can_control, model, os_version}`. |
| `/app/phones/snapshot` | `phones:read` | `slot`, `width` (optional, 120-2000) | `{image, content_type}`: `image` is the JPEG, **base64-encoded** (the REST equivalent returns the raw bytes instead). |
| `/app/phones/ocr` | `phones:read` | `slot`, `width` (optional, 120-2000) | `{text}`: the on-screen text. |
| `/app/phones/input` | `phones:control` | `slot`, `op` (`tap`, `swipe`, `hotkey`, `type`) plus op fields (see below) | `{op}`. Runs synchronously; no run record. |
| `/app/phones/commands` | `phones:control` | `slot`, `op`, plus `text`/`url`/`level`/`on` depending on `op` | `202` with a phone run (see [Runs](#runs)). |
| `/app/phones/macros` | `phones:control` | `slot`, and either `workflow` + `params`, or `steps` (mutually exclusive) | `202` with a phone run. |
| `/app/phones/agent-runs` | `phones:control` | `slot`, `task` (string, up to 2000 chars) | `202` with a phone run. |
| `/app/phones/runs` | `phones:read` | `slot`, `page` (optional) | `{data, meta}`: paginated phone runs for that slot. |
| `/app/phones/runs/get` | `phones:read` | `slot`, `run` (run UUID) | One phone run, or `404` if it belongs to another slot or is not visible to you. |

`/app/phones/input` op fields: `tap` needs `fx`/`fy`; `swipe` needs `fx1`/`fy1`/`fx2`/`fy2`
and takes an optional `steps` (1-500, default 20); `hotkey` needs `key` (one of `home`,
`app_switcher`, `control_center`, `notifications`, `paste`, `run_shortcut`); `type` needs
`text`. All coordinates are fractions of the screen, 0 to 1.

`/app/phones/commands` ops: `clipboard_set` (`text`), `clipboard_get`, `open_url` (`url`),
`reboot`, `clear_photos`, `get_ip`, `brightness` (`level`, 0-1), `wifi`/`airplane`/`cellular`/
`flashlight` (`on`, boolean).

`/app/phones/macros` `params` is an object of named values, each a string, number or
boolean. Any other value type is rejected with `422`.

### Runs

`/app/phones/commands`, `/app/phones/macros` and `/app/phones/agent-runs` each create a
**phone run** and reply `202` with it immediately. The run moves through `queued`,
`running`, `succeeded`, `failed` or `cancelled` as the phone does the work. A run looks
like:

```json
{
  "id": "...",
  "slot": "...",
  "kind": "macro",
  "status": "succeeded",
  "label": "post-to-story",
  "result": { ... },
  "error": null,
  "started_at": "...",
  "finished_at": "...",
  "created_at": "..."
}
```

Poll it with `/app/phones/runs/get`, or watch for the `run` [event](#events) instead of
polling.

## Accounts

Ability: `accounts`.

| Fun | Data fields | Returns |
|---|---|---|
| `/app/accounts/list` | `page` (optional), `platform` (optional) | `{data, meta}`: paginated accounts. |
| `/app/accounts/get` | `account` (UUID) | One account. |
| `/app/accounts/create` | `platform` (optional, default `tiktok`). TikTok: `username`, `password`, `slot` (or `already_signed_in` to skip login). Instagram and YouTube: `handle`. | `201` with the created account. If `slot` names a phone, the token also needs a control grant on it. |
| `/app/accounts/delete` | `account` | `204` with `data: null`. |
| `/app/accounts/login` | `account` | `202` with the account. |
| `/app/accounts/logout` | `account` | `202` with the account. |
| `/app/accounts/stop-login` | `account` | `202` with the account. |

## Submissions and uploads

Ability: `submissions`.

| Fun | Data fields | Returns |
|---|---|---|
| `/app/submissions/list` | `page` (optional), `platform` (optional) | `{data, meta}`: paginated submissions. |
| `/app/submissions/get` | `submission` (id) | One submission. |
| `/app/submissions/create` | `platform` (optional), `account`, one of `video_url`/`upload_id`, `caption`, `draft` (optional) | `201` with the created submission. |
| `/app/submissions/cancel` | `submission` | The submission; `409` if it already finished. |
| `/app/submissions/delete` | `submission` | `204` with `data: null`. |
| `/app/submissions/upload-url` | *(none)* | `201` with `{upload_id, upload_url, expires_at}`. For a client that cannot send multipart, upload the video to `upload_url` and pass `upload_id` to `/app/submissions/create`. |

## Billing

Ability: `billing`.

| Fun | Data fields | Returns |
|---|---|---|
| `/app/billing/summary` | *(none)* | The billing summary: subscription state, phone count, price, pending orders. |
| `/app/billing/rentals` | Either `phones` + `country`, or `items` (array of `{country, quantity}`), plus `accept_terms: true` | `201` with `{checkout_url, ...}`. |
| `/app/billing/requests` | Either `phones` (new total) or `add` (signed delta), plus `accept_terms: true` | `201` if applied under your standing allowance, otherwise `202` pending approval. |
| `/app/billing/requests/get` | `request_id` | The billing request's status, or `404` if it is not yours. |

`accept_terms: true` is required on both billing funs, the same as the REST and MCP
equivalents: show the user the terms and privacy links before sending it.

## Events

The connection also receives push frames you did not ask for, shaped `{event, data}`:

| Event | Who receives it | Data |
|---|---|---|
| `run` | Connections viewing that run's slot | The phone run (same shape as `/app/phones/runs/get`), sent on creation and on every status change. |
| `account` | The account's owner | The account, sent when its `status` changes. |
| `submission` | The submission's owner | The submission, sent when its `status` changes. |
| `billing_request` | The request's owner | `{request_id, status, to_phones, approval_url, expires_at, resolved_at}`, sent when its `status` changes. |

Use these instead of polling: open the connection, keep it alive, and react to the events as
they arrive.

## Example session

```json
→ { "fun": "/app/phones/list", "msgid": "1", "data": {} }
← { "fun": "/app/phones/list", "msgid": "1", "status": 200, "data": [
      { "slot": "b3f...", "name": "slot4", "video_live": true, "input_present": true,
        "can_control": true, "model": "iPhone 13", "os_version": "17.4" }
    ] }

→ { "fun": "/app/phones/macros", "msgid": "2",
    "data": { "slot": "b3f...", "workflow": "post-to-story", "params": {} } }
← { "fun": "/app/phones/macros", "msgid": "2", "status": 202,
    "data": { "id": "run-1", "slot": "b3f...", "kind": "macro", "status": "queued",
              "label": "post-to-story", "result": null, "error": null,
              "started_at": null, "finished_at": null, "created_at": "..." } }

← { "event": "run", "data": { "id": "run-1", "status": "running", ... } }
← { "event": "run", "data": { "id": "run-1", "status": "succeeded", "result": { ... }, ... } }
```
