# API reference

Mobilebox is an API + MCP runtime for cloud Android phones for AI agents (Android 14, arm64): $0.01 a phone-minute, ready for a first screenshot in 19.4 s in our November 2026 benchmark. Bring your own agent: the service does not run a language model or accept a natural-language task endpoint.

[OpenAPI 3.1 specification](/openapi.json) · [Machine-readable index](/llms.txt) · [Agent instructions](/skill.md) · [Errors and recovery](/docs/errors)

## Authentication

No key yet: [setup.md](/setup.md) walks an agent through it. `POST /v1/signup/email {email, key_name?, via?}` mails the user a 6-digit code. For a new address it also answers with `api_key` (shown once, stored only as a hash), `key`, `project`, `claimed: false`, `balance_usd` (a small trial), `expires_at`, `message_for_user` (tell your user this line), `mcp_url` and `next_steps`: the key works at once, for one phone at a time, and without the code the workspace closes at `expires_at` (7 days; its phones are deleted). An address that already has a workspace, or one waiting for its code, gets only `{sent: true, next}`, never a second key. `POST /v1/signup/verify {email, code, key_name?, via?}` confirms: with `Authorization: Bearer <that key>` it answers 200 `claimed: true` and the key keeps working; without it, 201 with a new `api_key` and `key {id, name, hint, created_at}` (keys issued before the code end), plus `project`, `new_account`, `balance_usd`, `mcp_url` and `next_steps`, and `message` while the workspace has no credit yet. Neither needs a browser. A confirmed new address gets the starter credit; a known email account gets a new key for its own project; an address that signs in with GitHub or Google answers 409 `account_exists` (sign in there and create the key in the dashboard). A code works once, for 10 minutes; 5 wrong tries or a newer code retire it (400 `auth_failed`). Too many codes: 429 with `retry_after`. Store the key in the user's secret store or environment; never print it.

Send Authorization: Bearer YOUR_API_KEY on phone-control /v1/* and /mcp requests. Exception: /v1/account/keys is browser-session-only account management, not an agent API; create/list/revoke keys in the dashboard. Issued agent keys cannot become browser sessions. Browser login and API-key authentication are separate. Get your key from the account dashboard; do not send a browser session cookie as an API bearer key. Existing SDK/CLI variables remain MOBILESANDBOX_API_KEY and MOBILESANDBOX_URL for compatibility. Public product name: Mobilebox.

```bash
export MSB=https://mobilebox.dev
# Load MOBILESANDBOX_API_KEY from your secret manager or shell environment.
curl --fail-with-body "$MSB/v1/account" \
  -H "Authorization: Bearer $MOBILESANDBOX_API_KEY"
```

JSON bodies require Content-Type: application/json. Unknown fields, malformed JSON, and wrong types fail with 400 before the requested operation. JSON bodies are limited to 256 KB. Installation accepts separate binary uploads up to 300 MiB.

## Phone lifecycle

| Method and path | Request | Result |
|---|---|---|
| POST /v1/phones | Optional name, country, city, network, platform, device, idle_timeout, max_duration, timezone, locale, location, proxy, task; optional Idempotency-Key header | 201 phone, possibly still creating |
| GET /v1/phones | None | data array of this project’s phones |
| GET /v1/phones/{id} | None | Phone metadata and status |
| POST /v1/phones/{id}/start | Optional idle_timeout, max_duration | Starts or waits on existing phone |
| POST /v1/phones/{id}/park | No body | Shuts down, keeps apps/data/logins |
| DELETE /v1/phones/{id} | No body | 204; deletes phone and data |
| GET /v1/phones/{id}/sessions | None | Session timestamps, billed seconds, costs, end reasons |
| GET /v1/account | None | Project id, spent_usd, balance_usd, configured price, devices; email when available in a browser session |
| POST /v1/account/welcome | One of: wants (list of ios_real, android_real, android_cloud, live_view, other; optional wants_other), source (chatgpt, claude, perplexity, google, github, x, reddit, hackernews, friend, other; optional source_other), or skip: true | The optional questions the console asks once after sign-up; answers welcome: source or done |
| PUT /v1/account/devices | devices: list of android_cloud, android_real, iphone, ipad | The saved answer to "Which devices do you need?" (see below) |

Create, get, and start accept wait=ready or wait=parked and timeout=0–55 whole seconds. Default timeout: 50. A wait timing out can still return a creating/starting phone successfully. Inspect status and poll GET on the same id until ready, failed, or your own deadline; do not create duplicates.

```json
{
  "id": "ph_example",
  "object": "phone",
  "name": "smoke-test",
  "platform": "android",
  "device": "cloud",
  "status": "ready",
  "created_at": "2026-01-01T00:00:00.000Z",
  "settings": { "timezone": "Europe/Berlin", "locale": "de-DE" },
  "session": {
    "id": "ses_example",
    "started_at": "2026-01-01T00:00:00.000Z",
    "ready_at": "2026-01-01T00:00:25.000Z",
    "last_active_at": "2026-01-01T00:00:25.000Z",
    "idle_timeout": 300,
    "max_duration": 900,
    "reserved_usd": "0.150000"
  },
  "failure": null
}
```

This is an illustrative shape, not a recorded session. Settings reflect the configured fields. Phone status can be creating, starting, ready, parking, parked, failed, or deleted. Inspect failure.code and failure.message when failure is present; failed phones can be started again.

`task` is one line on what your agent is trying to do ("test the sign-up flow of my Android app"). It's optional, helps us build what agents need, and is never shown publicly; MCP's `create_phone` takes it too.

Defaults: idle_timeout 300 seconds, max_duration 900 seconds. Their ranges are 60–3,600 seconds and 60–10,800 seconds respectively. A session reserves its maximum charge against available balance; inspect the account and session endpoints for the configured rate and actual billed usage. All keys in the same project share its phones and balance. When a create needs more room, the answer says we're scaling your workspace and links a call to raise it (see [errors](/docs/errors)).

## Devices

| Device | Price | Ask with |
|---|---|---|
| Cloud Android 14 phone (arm64) | $0.01 per phone-minute | default |
| iPhone and iPad | $0.05 per phone-minute | platform: "ios" |
| Real Android device | $0.03 per phone-minute | device: "real" |
| Dedicated devices | from $79 per month | the call below |

Every response says which platform and device you got, and each is billed at its own price. A `notice` field carries anything else worth knowing about the phone you got; errors such as `setup_required` are in [errors](/docs/errors).

MCP create_phone takes the same platform and device. The dashboard asks "Which devices do you need?"; PUT /v1/account/devices saves that answer. Volume over 100 devices: contact sales on the same call.

## Retry a create safely

Send an Idempotency-Key header (1–64 printable ASCII characters with no space at either end; a UUID is safest) with POST /v1/phones to make the create safe to retry after a lost response. The MCP create_phone tool takes the same value as idempotency_key; the SDK as create({ idempotencyKey }); the CLI as create --idempotency-key.

| Retry within the window | Result |
|---|---|
| Same key, same options | 201 with the same phone in its current state, including parked or failed. No new phone, session, reservation, boot, or limit/credit check. |
| Same key, other options | 409 idempotency_conflict; the error does not repeat your input |
| Same key, phone was deleted | 410 phone_deleted; use a new key for a new phone |
| Rejected create (400, 402, 429, 503) | Nothing is claimed; fix the cause and retry with the same key |

Keys are scoped to the project, not the API key. A key holds for 24 hours from the phone's creation, and longer while that phone is still creating; after that the same key creates a new phone. Options are compared as the phone would run them: omitted idle_timeout, max_duration and name equal their defaults, country equals the timezone, locale and location it sets, and a null proxy equals none. The wait and timeout query parameters are not options. Later changes to the phone do not make a retry a conflict. Without the header, every POST creates a phone as before. The key covers create only; start, park and delete have no key.

HTTP removes spaces at the ends of a header value before the API sees it, so a raw REST key " abc " arrives as "abc". The SDK, CLI and MCP refuse such keys with 400 invalid_request rather than change them. The key, its phone and the reservation are saved together before the phone boots, so they survive an API crash or restart. A host power loss can lose the most recent create, along with its key and phone.

## Read the screen

GET /v1/phones/{id}/observe returns app, elements, and text. Add ?format=text for a compact numbered list. GET /v1/phones/{id}/screenshot returns image/png. The phone must be ready.

```text
[4] Button "Save" (540,1080)
[5] TextField "Draft" [focused] (540,865)
```

Illustrative lines: ref, element type, text, state, center. Observe again after a screen change; old refs may fail with stale_ref. A screenshot is useful for images, maps, or canvases absent from the accessibility tree.

## Watch live

POST /v1/phones/{id}/view returns { url, expires_at, command } for a running phone. The url opens a viewer that shows the phone's screen live with each action drawn on it: a cursor moving to the target, a ripple per tap, the path of each swipe, the text being typed (dots for password fields), and a caption per step ("Tapped "Search""), also announced to screen readers. The viewer is read-only, and watching does not count as activity on the phone. command is `npx mobilebox watch "<url>"`, which opens the viewer in its own app window (Chrome or Edge, else the default browser); `npx mobilebox watch <phone_id>` asks for a link with MOBILESANDBOX_API_KEY first. The dashboard's Phones page has a Watch button on every running phone.

The link is short-lived and grants only this phone's view in this workspace; the access token is in the URL fragment, so it reaches no server log. Ask for a new link after expires_at. The view ends when the phone parks or is deleted.

## Send actions

POST /v1/phones/{id}/actions accepts an actions array of 1–20 items. It returns results and a fresh screen. It stops on first failure; earlier actions stay applied. HTTP 200 can contain a failed action: inspect every results[i].ok. Do not replay an entire batch blindly.

```json
{
  "actions": [
    { "type": "open_app", "package": "com.android.settings" },
    { "type": "home" }
  ]
}
```

```json
{
  "results": [{ "type": "tap", "ok": false,
    "error": { "code": "stale_ref", "message": "Illustrative error",
      "next": "Observe again and use a current target.", "retryable": false } }],
  "screen": { "app": "com.android.settings", "elements": [], "text": "Illustrative screen" }
}
```

The example shows the failure envelope, not a live response. Screen app and element details are described in OpenAPI. A batch stops starting new actions after 120 seconds. Use wait_for for a screen condition; avoid long blind sleeps.

## Target vocabulary

| Shape | Meaning |
|---|---|
| {"text":"Save"} | Exact match, then contains; optional nth starts at 1 |
| {"ref":4} | Reference from the most recent observed screen |
| {"id":"com.app:id/save"} | Resource id |
| {"desc":"Navigate up"} | Accessibility content description |
| {"x":540,"y":1080} | Raw pixels; verify with a current screenshot |

## Action vocabulary

| type | Fields and limits |
|---|---|
| tap | target |
| long_press | target; duration_ms 100–10,000, default 800 |
| swipe | from and to as [x,y]; duration_ms 50–5,000, default 300 |
| scroll | direction: up, down, left, right |
| scroll_to | text; direction up/down (default down); max_swipes 1–30 (default 10) |
| type | text up to 2,000 chars; target optional; clear/submit default false |
| key | key: back, home, enter, delete, recents, tab |
| back / home | No additional fields |
| open_app / close_app | package; close_app backgrounds then force-stops |
| wait | ms 0–10,000 |
| wait_for | text; gone default false; timeout_ms 100–30,000, default 10,000 |

Coordinates are integers from 0 to 10,000. Text targets are nonempty and up to 500 characters. Ref range: 1–10,000. Text nth range: 1–100. type needs a focused field: tap it first or pass target. type_not_applied means text did not reach it.

## Network

| Network | Price | Ask with |
|---|---|---|
| Standard network | included | default (network: "datacenter") |
| Residential IP: city targeting, sticky per phone | $8 per GB | network: "residential" |
| Mobile carrier IP: city targeting, sticky per phone | $15 per GB | network: "mobile" |
| Your own proxy | included | proxy |

Add country and city to choose where, e.g. `{"network": "residential", "country": "DE", "city": "Berlin"}`. A city sets GPS, timezone and locale to match it (Berlin: 52.52, 13.405, Europe/Berlin, de-DE), and implies its country. Any city name works (letters, spaces and hyphens, up to 64): for one we're still setting up, the phone uses its country's location meanwhile and `notice` says so. Residential and mobile traffic is billed per GB. Your own proxy wins over network. A `notice` field says anything worth knowing about the network the phone is on. PATCH /v1/phones/{id}/device and MCP set_device take network, country and city too.

Acceptable use: no fake engagement, fake accounts, spam or evading platform protections ([terms](/terms#use)).

## Install and manage apps

POST /v1/phones/{id}/apps accepts binary APK/.apks/.xapk bytes, or JSON {"url":"https://download.example/app.apk"}. Optional grant_permissions=true is accepted in the query or JSON install body. The response is 201 with package, version_code, and version_name. Split selection uses arm64-v8a, density, language, base and feature splits.

GET /v1/phones/{id}/apps lists user-installed packages and version_code. DELETE /v1/phones/{id}/apps/{package} uninstalls and deletes app data, returning 204. Updating retains data when the signature is compatible; signature_mismatch requires an intentional fresh-install decision, not automatic uninstall.

## Configure device settings

GET /v1/phones/{id}/device reads adapter-reported settings. PATCH accepts country and city (GPS, timezone and locale together), network (see [Network](#network)), timezone (IANA), locale (BCP-47), location {lat,lng}, and proxy (a supplied socks5:// URL or null for direct egress); explicit fields win over the ones a city sets. These settings persist across park/start. Changing locale restarts the UI; observe again afterward.

Country and city are shortcuts for timezone, locale, and GPS; the IP comes from the network (see [Network](#network)). Proxy credentials are redacted from phone metadata; keep secrets out of your own logs and screenshots too.

## Python client

`pip install mobilebox` ([PyPI](https://pypi.org/project/mobilebox/)): a typed client for the REST API, Python 3.10 and newer, standard library only. `Mobilebox()` reads MOBILESANDBOX_API_KEY and MOBILESANDBOX_URL (default https://mobilebox.dev), waits 65 s per request and sends `mobilebox-python/<version>` as User-Agent; pass `api_key`, `base_url`, `timeout` or `user_agent` to change them. [Quickstart example](/docs#use-python).

| Method | Does | REST |
|---|---|---|
| `create_phone(**options)` | Create a phone and return it ready (`wait=False` returns at once; `idempotency_key=` to retry across processes). Options are the create fields above | POST /v1/phones |
| `list_phones()`, `get_phone(id)` | This project's phones, one phone | GET /v1/phones, GET /v1/phones/{id} |
| `park_phone(id)`, `start_phone(id)` | Shut down and stop billing; resume with apps and data | POST /v1/phones/{id}/park, /start |
| `delete_phone(id)` | Delete the phone and everything on it | DELETE /v1/phones/{id} |
| `observe(id)` | The screen: `Screen(text, app, elements)`; `screen.find(text)` returns the element | GET /v1/phones/{id}/observe |
| `act(id, actions)` | Run 1–20 actions, stop at the first failure: `ActResult(results, screen)` | POST /v1/phones/{id}/actions |
| `screenshot(id)` | PNG bytes | GET /v1/phones/{id}/screenshot |
| `install_app(id, url=None, file=None, grant_permissions=False)` | Install an APK, .apks or .xapk from a public URL, a path or bytes | POST /v1/phones/{id}/apps |
| `watch(id)` | A read-only live view link for a person | POST /v1/phones/{id}/view |
| `request(method, path, body)` | Any other endpoint, with the same auth and errors | Any |

Every API error raises `MobileboxError` with `code`, `message`, `next`, `retryable`, `retry_after` and the HTTP `status`; do what `next` says before retrying. A failed action is not an exception: `act` returns `result.ok == False` and `result.error`, earlier actions stay applied, and you continue from `result.screen`. `create_phone` sends an Idempotency-Key (a UUID unless you pass one) and retries retryable errors with it; `phone_limit` and `box_full` are not retried.

For LangChain and LangGraph agents, `pip install langchain-mobilebox` ([PyPI](https://pypi.org/project/langchain-mobilebox/)) wraps this client as tools: `MobileboxToolkit().get_tools()` returns seven tools (`mobilebox_create_phone`, `mobilebox_observe`, `mobilebox_act`, `mobilebox_screenshot`, `mobilebox_install_app`, `mobilebox_park_phone`, `mobilebox_delete_phone`), or pass `client=` to single tools like `MobileboxObserve` and `MobileboxAct` so your code owns the phone. Errors come back to the model as text with `code` and `next`. [LangGraph example](/integrations/langgraph).

## MCP mapping

The authenticated /mcp endpoint uses stateless Streamable HTTP. Tools: list_phones, create_phone, start_phone, park_phone, delete_phone, install_app, list_apps, uninstall_app, observe, act, screenshot, set_device, watch_phone, request_capability. request_capability {what, why?} records something the agent needed that Mobilebox doesn't offer yet, for the team to answer. Call tools/list through a compatible client for the actual input schemas. create_phone accepts idempotency_key with the same rules as the Idempotency-Key header. MCP installation accepts a URL; use REST for local binary uploads. REST exposes account/session/device-read endpoints beyond the current tool set. Every /mcp request needs a key or an OAuth access token on that same request; without one it answers 401 with a WWW-Authenticate header that names the sign-in (see [MCP sign-in](#mcp-sign-in)). Server card: GET /mcp/server-card, listed in /.well-known/ai-catalog.json.

MCP tool errors set isError and include code, message, and next guidance in text. An act result can contain a failed action within its normal result text; inspect the full result. Keep your API key out of model-visible prose.

## MCP sign-in

Clients with MCP OAuth (Claude and ChatGPT connectors, Claude Code, Cursor, VS Code) need no key: add https://mobilebox.dev/mcp and the client signs your user in. A request without credentials gets 401 with WWW-Authenticate: Bearer resource_metadata="https://mobilebox.dev/.well-known/oauth-protected-resource/mcp"; the same document is also at /.well-known/oauth-protected-resource. It names the authorization server, https://mobilebox.dev, described at /.well-known/oauth-authorization-server.

- Registration: POST /oauth/register (RFC 7591). Every client is public (token_endpoint_auth_method none). redirect_uris: https, http on loopback (any port at sign-in) or an app scheme, no fragment.
- Authorization: GET /oauth/authorize with response_type=code, PKCE (code_challenge_method=S256 only), state and resource=https://mobilebox.dev/mcp. Your user signs in (GitHub, Google or an email code) and chooses Allow or Cancel for one workspace. The consent page names your client by its client_name only when the redirect goes back to a known client (claude.ai, claude.com, chatgpt.com, chat.openai.com, vscode.dev, loopback, or the cursor, vscode and vscode-insiders schemes); otherwise it leads with the return host and shows client_name as what the client calls itself, not verified. The code works once, for 5 minutes; using it twice revokes what it gave.
- Tokens: POST /oauth/token (form-encoded) with authorization_code or refresh_token. Access tokens last an hour and work on /mcp only, not REST. Refresh tokens rotate: each works once, and a used one coming back revokes the connection. POST /oauth/revoke ends the connection.
- Your user sees connected apps under API keys in the dashboard and can revoke any of them at once.
