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:
Code
The response includes a socket_url:
Code
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. 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:
Code
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:
Code
A failed call replies with an error status, a message, and field errors when the failure was a validation failure:
Code
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). |
/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:
Code
Poll it with /app/phones/runs/get, or watch for the run event 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
Code

