Phonebox / Docs
Interfaces

MCP

Connect Claude Code, Cursor or any MCP client to the hosted Phonebox server, and use its tools.

View as Markdown

Phonebox runs a hosted MCP server at https://phonebox.dev/mcp. It speaks Streamable HTTP and authenticates with the same API keys as the REST API. Every tool call is a request to the REST API made with your key, so scopes, limits and billing are exactly the same, and the console's activity shows each call as coming from MCP.

Connect a client

Every request carries your key as a bearer token:

Authorization: Bearer pbx_…

The server doesn't use OAuth, so your client has to be able to send this header.

Claude Code

claude mcp add --transport http phonebox https://phonebox.dev/mcp --header "Authorization: Bearer pbx_…"

To share the server with a project without sharing the key, commit a .mcp.json at the project's root that reads the key from each person's environment:

{
  "mcpServers": {
    "phonebox": {
      "type": "http",
      "url": "https://phonebox.dev/mcp",
      "headers": { "Authorization": "Bearer ${PHONEBOX_API_KEY}" }
    }
  }
}

Cursor

Add the server to ~/.cursor/mcp.json to use it in every project, or to .cursor/mcp.json in one project:

{
  "mcpServers": {
    "phonebox": {
      "url": "https://phonebox.dev/mcp",
      "headers": { "Authorization": "Bearer pbx_…" }
    }
  }
}

Keep a file that holds a key out of version control.

Other clients

Point any client that supports Streamable HTTP at https://phonebox.dev/mcp, and have it send the Authorization header. The server is stateless: it accepts only POST requests, holds no session between them, and takes one JSON-RPC message per request, so it refuses JSON-RPC batches.

Tools

ToolWhat it doesArguments
list_phonesLists the project's phones, newest first, with their status and when each parks: up to 50 a page, and next_cursor for the next page. Without status, it leaves out failed and deleted phones.status and cursor, both optional.
get_phoneReads one phone: its status, session and when it parks. With wait, waits until it is ready or parked, only reading it, so it is the way to wait for a phone that is still creating or starting.phone_id, and optionally wait, "ready" or "parked".
create_phoneCreates a phone and waits until it is ready.All optional: name, country, metadata, idle_timeout, max_duration and idempotency_key.
start_phoneStarts a parked phone, or renews a running one, and waits until it is ready.phone_id, and optionally idle_timeout and max_duration.
park_phoneParks a phone to stop billing, and waits until it has stopped.phone_id.
observeReads the screen, and adds a screenshot 720 pixels wide as an image.phone_id, and screenshot, which is true by default.
actRuns up to 20 actions in order, stopping at the first failure.phone_id, actions, and observe, which is ui by default.
list_appsLists the apps installed on the phone: package, label, version, and whether it came with the phone.phone_id.
reboot_phoneRestarts a stuck phone, keeping everything on it, and waits until it is ready again. A phone can reboot once every 10 minutes. A reboot the phone service didn't confirm is never sent twice: wait with get_phone instead.phone_id.
install_appWith package, installs an app from Phonebox's app library in the background. It doesn't reach Google Play: a Play Store app installs through the Play Store app on the phone. With upload, installs your own APK and waits until the phone lists it, or fails with install_failed and what to do. While that upload is still installing on the phone, and for 10 minutes after that install succeeds, calling it again with the same arguments starts nothing: it waits for that install, or reports it. When the phone doesn't confirm an install's start, it follows Phonebox's record of the install to its end. While two installs run, or while the same app is still installing, another answers install_in_progress.phone_id, and package or upload; with upload, optionally replace.
create_app_uploadStarts an upload of your own APK. MCP can't carry a file, so it returns a one-off upload_url and the exact curl command that sends the APK there from your shell.None.
complete_app_uploadFinishes an upload once its file has arrived, and waits while Phonebox reads and stores the APK. Calling it again with the same arguments only waits again.upload_id, and storage_id: the storageId that upload_url answered.
live_view_urlCreates a live view link for a person to watch and control the phone. Anyone with the link controls the phone until it expires, so share it only with your user, and never paste it into logs or shared chats.phone_id, and expires_in in seconds, which is 3600 by default.

get_phone reads one phone. With wait set to "ready" or "parked", it waits for the phone the way GET /v1/phones/{id}?wait=… does, without starting or renewing it, so it is the way to wait for a phone that is still creating or starting.

There is no tool that deletes or resets phones, because both wipe the phone. Delete or reset phones through the API or the CLI with an Admin key.

Install your own build

A coding agent with a shell installs the APK it just built in four steps:

  1. Call create_app_upload. Its result has the upload's id, its upload_url, and the curl command to run.
  2. Run that command with your APK's path, such as app/build/outputs/apk/debug/app-debug.apk. It answers {"storageId": "…"}. Over the REST API, that answer goes to POST /v1/apps/uploads/{id}/complete as it is.
  3. Call complete_app_upload with the upload's ID and the storageId. It answers once the upload is ready, or with the reason it can't be installed, such as a file that isn't a signed APK.
  4. Call install_app with the phone's ID and upload. It answers once the phone lists the app. If the result is still running, with a note saying so, call install_app again with the same arguments: while that install runs, and for 10 minutes after it succeeds, a repeat only waits for it or reports it, and never starts a second one (or uninstalls the app again for replace). After an install fails, calling it again installs again, as its next step says. If the phone already has a build of the app signed with another key, or a newer one, install with replace: true, which uninstalls the app first and deletes its data.

Uploads describes the upload's limits and statuses.

The tools carry the usual MCP hints for clients that decide what to confirm with you. list_phones, list_apps and observe only read. act is marked destructive, because an action can do anything a person can do on a phone, such as clearing an app's data. park_phone is safe to repeat.

Unknown arguments are refused, so a misspelled argument fails instead of being ignored.

Results

A successful call returns {"data": …, "request_id": "req_…"}, both as JSON text and as structured content. data is what the REST API returned.

observe returns the screen in the form a model reads best. The text starts with the snapshot ID, continues with the text form, and ends with the screenshot's size and scale. The screenshot itself follows as an image:

snapshot snp_4f2k7m3q6z3a
app com.android.launcher3
keyboard hidden
screen 1080x2400
[1] EditText "Search apps" #search clickable editable (540,250)
[2] TextView "Chrome" #icon clickable (200,1900)
[3] TextView "Settings" #icon clickable (500,1900)
[4] TextView "Play Store" #icon clickable (800,1900)
screenshot 720x1600, scale 0.666667 (device pixels = image pixels / scale)

Coordinates in the text are device pixels. To tap a point you found on the image, divide its coordinates by scale. act returns the batch result as JSON, followed by the screen after the batch in the same form, unless the screen couldn't be read, or not in time. Without the screen, call observe before you act again. When the call has a progressToken, act sends a progress notification for each action as the batch runs, which also keeps the connection active during a long batch.

A tool call makes one REST request, or several when a waiting tool keeps waiting. Its request_id names the request whose data it returns, which is the last one, while the HTTP response's X-Request-Id names the first. The console's activity has a row for each of them.

Waiting tools

create_phone, start_phone, park_phone, and get_phone with wait, wait for the phone; complete_app_upload waits for its upload, and install_app with upload for its install. How long they wait depends on your client:

  • A call without a progressToken returns within about 50 seconds, the length of one REST wait; complete_app_upload and install_app within about 45. An upload still processing is waited on again by repeating complete_app_upload, and an install still running by repeating install_app: both repeats only wait. If the phone is still creating or starting then, the result is the phone as it is, and calling get_phone with its ID and wait set to "ready" waits again, only reading the phone. Calling start_phone again would renew a running phone's session, which reserves credit again. A park that hasn't finished can be waited on the same way with park_phone.
  • A call with a progressToken keeps waiting for up to about 100 seconds. The tool sends a progress notification before each wait, which also keeps the connection active.

The limit exists because a response that sends nothing for 60 seconds is closed by many proxies on the way.

Once create_phone has created a phone, it always returns that phone. If a later wait fails, for example on a rate limit, the result is the phone as last seen, and only the phone's own failure is reported as an error. If the call itself fails or times out before you see a result, repeat it with the same idempotency_key and arguments to get the same phone instead of a second one. A repeat returns the phone as it is now, even one that has failed since, with its failure: then create the next phone with a new idempotency_key. Without a key, call list_phones and use the newest phone with the name you gave it before you create another.

Errors

A refused call is a tool result with isError set to true. Its text, and its structured content, is the REST API's error envelope, with the code, the message, whether it is retryable, the next step and the request ID. An internal_error from a tool that changes the phone isn't retryable, because the change may have happened: read the phone before you call it again. MCP has no headers, so a Retry-After wait appears as details.retry_after_seconds. Every message you send counts toward your key's limit of 600 requests a minute, initialize and tools/list included, and one over the limit is refused with 429 rate_limited and a Retry-After wait. A failed phone's own failure carries no wait, because it isn't retryable: create a new phone, with a new idempotency_key if you use one.

A call that ends without a tool result, for example because the connection dropped or timed out, may still have run. After act or another tool that changes the phone, call observe before you call it again.

Arguments that don't match a tool's schema are refused before any request is made, in the MCP library's own error shape. The text names the tool and each problem:

{
  "content": [
    {
      "type": "text",
      "text": "Input validation error: Invalid arguments for tool observe: phone_id: Invalid string: must match pattern /^ph_[a-z2-7]{12}$/"
    }
  ],
  "isError": true
}

The server checks the form of your key on every request, and it answers a missing or malformed key with 401 and a WWW-Authenticate header. It checks whether the key exists, has expired or was revoked when a tool runs, so a key that no longer works shows up as a tool error on the first tool call, while the handshake and the tool list still succeed. A request that carries an Origin header is refused, as on the REST API.

On this page