Lovely RouterDocs
Get a key
API reference

Phone endpoints

Every /v1/phones endpoint for cloud phones, apps, the live screen, direct control and agent runs, with parameters, limits, responses and errors.

The cloud phone API lives under https://lovelyrouter.com/v1/phones. Authenticate every request with your API key as Authorization: Bearer $LOVELY_API_KEY (Authentication). Bodies are JSON, except APK uploads and screenshots. A phone costs $1.20 per awake hour, billed per minute from your prepaid balance; see what a phone costs.

For a walkthrough, start with the cloud phone overview. Coordinates everywhere are pixels of the screenshot, whose short side is 540 pixels: 540×1200 upright, 1200×540 when the phone is turned to landscape (a game, a video). The screenshot's width and height say which.

The phone object#

Attributes

idstring

ph_ followed by 20 characters.

objectstring

Always phone.

statusstring

starting (about a minute, longer while startup apps install), ready, failed or deleted.

namestring | null

Your label.

userstring | null

Your own end user's id, if you set one.

metadataobject | null

What you stored with it.

startup_appsarray

One entry per app you listed in apps: source, status (pending, installed or failed), packages (the package names it installed) and error.

created_atready_at

Unix seconds.

deletes_after_idle_atinteger | null

When the phone is deleted unless it's used before then (1 hour after its last activity).

awake_secondsinteger

Awake time billed so far.

charge_usdnumber

What the phone has cost so far.

end_reasonstring | null

Why it was deleted: deleted (by you), idle, balance or failed.

errorstring | null

Why it failed to start.

Create a phone#

POST/v1/phones

Creates a phone and returns 201 with the phone object while it starts. Follow it with Retrieve a phone and wait. You need 15 minutes of phone time on your balance. Each account can have 3 phones at a time.

Body parameters

appsarray of strings

Up to 20 apps to install before the phone is ready: https:// links to .apk, .apks or .xapk files, or names of uploaded APKs. A failed install doesn't fail the phone; check startup_apps.

namestring

A label, up to 80 characters.

userstring

Your end user's id (up to 200 characters), to list their phones with GET /v1/phones?user=.

metadataobject

Up to 4 KB of your own data.

waitinteger

Seconds to hold the request until the phone is ready, up to 50.

cURL
curl https://lovelyrouter.com/v1/phones \
  -H "Authorization: Bearer $LOVELY_API_KEY" \
  -H "content-type: application/json" \
  -d '{"apps": ["https://f-droid.org/F-Droid.apk"], "wait": 50}'
Response
{
  "id": "ph_2758eedebf08496f87e0",
  "object": "phone",
  "status": "starting",
  "name": null,
  "user": null,
  "metadata": null,
  "startup_apps": [{ "source": "https://f-droid.org/F-Droid.apk", "status": "pending" }],
  "created_at": 1793308400,
  "ready_at": null,
  "last_active_at": 1793308400,
  "deletes_after_idle_at": 1793312000,
  "awake_seconds": 0,
  "charge_usd": 0,
  "deleted_at": null,
  "end_reason": null,
  "error": null,
  "price_per_hour_usd": 1.20
}

Retrieve a phone#

GET/v1/phones/{id}

Returns the phone object. With wait (seconds, up to 50), a starting phone holds the request until it's ready.

List phones#

GET/v1/phones

Returns {"object": "list", "data": [phone, …]}, newest first. user= keeps one end user's phones; all=1 includes deleted ones.

Delete a phone#

DELETE/v1/phones/{id}

Deletes the phone with its apps and data, stops its run and settles its last minutes. Returns the phone object with status deleted. A phone is also deleted on its own after 1 hour without activity.

Get a screenshot#

GET/v1/phones/{id}/screenshot

Returns the screen now as a WebP image (image/webp, 540 pixels on its short side). With format=json, returns {screenshot, width, height, app, elements} with the image as base64.

cURL
curl https://lovelyrouter.com/v1/phones/ph_2758eedebf08496f87e0/screenshot \
  -H "Authorization: Bearer $LOVELY_API_KEY" -o screen.webp

List elements#

GET/v1/phones/{id}/elements

What's on screen, from Android's view tree: {"app", "activity", "width", "height", "data": [element, …]}. Each element has id, type (Button, EditText, TextView…), text, desc (the accessibility label), resource (the view id), x and y (its center), w and h, and flags (clickable, long-clickable, scrollable, editable, checked/unchecked, focused, selected, password, disabled). Screens drawn without views (games, some canvases) list few elements: use the screenshot.

Send an action#

POST/v1/phones/{id}/actions

Does one thing on the phone and returns {"ok": true} (or ok: false with error). Add "screenshot": true to get screenshot (base64 WebP) after the screen settles. Returns 409 while an agent run is driving the phone.

Actions

{"action": "tap", "x", "y"}

Tap a point.

{"action": "long_press", "x", "y", "ms"?}

Press and hold (default 800 ms).

{"action": "swipe", "x", "y", "to_x", "to_y", "ms"?}

Drag from one point to another (default 300 ms).

{"action": "scroll", "direction", "x"?, "y"?}

up, down, left or right; down shows what is below. At a point, or the middle of the screen.

{"action": "type", "text", "clear"?, "submit"?}

Type into the focused field, in any language. clear empties it first; submit presses Enter after.

{"action": "key", "key"}

back, home, enter, recents, delete, tab, escape, search, menu, power, volume_up, volume_down or wakeup.

{"action": "open_url", "url"}

Open a link: a web page in Chrome, or an app link (market://, an app's own scheme).

{"action": "launch", "package"}

Open an installed app, e.g. org.fdroid.fdroid.

{"action": "stop_app", "package"}

Force-stop an app.

{"action": "wait", "seconds"}

Up to 60 seconds.

{"action": "rotate", "orientation"}

Turn the phone: portrait or landscape. An app locked to one orientation (many games) keeps it. The live screen follows.

{"action": "shell", "command"}

An adb shell command on the phone; the answer has output.

cURL
curl https://lovelyrouter.com/v1/phones/ph_2758eedebf08496f87e0/actions \
  -H "Authorization: Bearer $LOVELY_API_KEY" \
  -H "content-type: application/json" \
  -d '{"action": "type", "text": "天气 Tokyo", "submit": true}'
POST/v1/phones/{id}/screen

Returns {"url", "expires_at"}: a page with the phone's live screen. Anyone with the link can watch and control the phone with mouse and keyboard, so share it only with the person meant to use it. It can be embedded in an iframe.

Body parameters

ttl_secondsintegerDefault 3600

How long the link works: 60 to 86,400. A screen already open keeps working.

view_onlyboolean

Opens the page in view-only mode. It is a display setting, not a permission: a viewer can turn it off.

Install an app#

POST/v1/phones/{id}/apps

Downloads and installs an app, and returns when it's done: {"ok", "packages", "error"} (422 when the install failed). Installing an app that is already there updates it.

Body parameters

urlstring

An https:// link to an .apk, .apks or .xapk file.

apkstring

Or the name of an uploaded APK.

GET /v1/phones/{id}/apps lists the installed apps' package names, and DELETE /v1/phones/{id}/apps/{package} uninstalls one.

Upload an APK#

PUT/v1/phones/apks/{name}

Stores an APK, so you can install it on any phone by name (letters, digits, _, ., -; up to 80 characters). Send the file as the request body, up to 100 MB; for a bigger file (up to 500 MB) use parts. .apk, .apks and .xapk files all work; an .xapk's game data (OBB files) is copied to the phone when it installs. Uploading to the same name replaces the file. Returns 201 with {name, bytes, sha256}. Each account can keep 50, and storage past 1 GB is billed.

GET /v1/phones/apks lists them, and DELETE /v1/phones/apks/{name} deletes one.

cURL
curl -X PUT https://lovelyrouter.com/v1/phones/apks/myapp.apk \
  -H "Authorization: Bearer $LOVELY_API_KEY" \
  --data-binary @myapp.apk

Upload a large APK#

POST/v1/phones/apks/{name}/uploads

For a file over 100 MB, up to 500 MB, upload it in parts. Start with the file's size, send each part, then finish. The result is the same as Upload an APK: it's stored under name and replaces a file of that name.

Body parameters

bytesintegerRequired

The file's size in bytes, up to 524,288,000 (500 MB).

It returns 201 with {id, part_size, parts, parts_done, expires_at}. Then:

  1. PUT /v1/phones/apks/{name}/uploads/{id}/{n} for each part n from 1 to parts, with the file's bytes from (n-1) × part_size as the body. Every part is part_size bytes (50 MB) except the last, which is the rest. Parts can go in any order or at the same time, and a part sent again replaces the earlier one.
  2. POST /v1/phones/apks/{name}/uploads/{id}/complete joins the parts and returns 201 with {name, bytes, sha256}, or 409 naming the parts still missing.

GET /v1/phones/apks/{name}/uploads/{id} shows which parts arrived (parts_done), and DELETE on it gives the upload up. An upload that isn't finished within 24 hours is discarded.

Storage#

Your first 1 GB of uploaded APKs is stored free. Past that, storage costs $0.30 per GB a month, taken from your balance every hour. Delete APKs you no longer need with DELETE /v1/phones/apks/{name}. Uploads need a balance above zero.

cURL
# 120 MB file: 3 parts
curl https://lovelyrouter.com/v1/phones/apks/game.xapk/uploads \
  -H "Authorization: Bearer $LOVELY_API_KEY" \
  -H "content-type: application/json" \
  -d "{\"bytes\": $(stat -c %s game.xapk)}"

split -b 50M -d -a 2 game.xapk part.
for n in 1 2 3; do
  curl -X PUT https://lovelyrouter.com/v1/phones/apks/game.xapk/uploads/upl_.../$n \
    -H "Authorization: Bearer $LOVELY_API_KEY" \
    --data-binary @part.0$((n-1))
done

curl -X POST https://lovelyrouter.com/v1/phones/apks/game.xapk/uploads/upl_.../complete \
  -H "Authorization: Bearer $LOVELY_API_KEY"

The run object#

Attributes

idstring

prun_ followed by 20 characters.

objectstring

Always phone.run.

phone_idstring

The phone it runs on.

statusstring

queued (the phone is starting), running, waiting (it asked you something), succeeded, failed or stopped (by you, or on reaching max_steps).

goalmodel

What you sent.

stepsinteger

Steps so far.

questionstring | null

While waiting: what the agent needs from you.

answerstring | null

The result, when it's done.

errorstring | null

Why it failed or stopped.

llm_usdnumber

What the model's tokens cost (set when the run ends).

tracearray

With trace=1: each step's step, note (what the agent saw and why it acted), action, args, app, ok, error and ms, plus unchanged: true when the action left the screen exactly as it was.

Start a run#

POST/v1/phones/runs

Creates a phone and starts the agent on it, or starts it on one of your phones with phone_id. Returns 202 with the run object. A phone runs one goal at a time: 409 while another run is active on it.

Body parameters

goalstringRequired

What to do, in plain words, with every value the task needs. Up to 10,000 characters.

phone_idstring

Run on this phone instead of a new one. POST /v1/phones/{id}/runs does the same.

appsname

For the new phone, as in Create a phone.

modestringDefault smart

smart: the model decides every step (a few seconds each). fast: a fast decision model picks nearly every step in well under a second (it always acts, which suits games), and model only writes the answer or a question for you. See Fast mode.

planbooleanDefault true

Fast mode only: a coach model runs beside the fast model (included in the phone's price). It writes a short strategy for the goal (for a game, which moves to prefer) and a note on the situation now, and rewrites both every few steps or when the run looks stuck. The fast model never waits for it: each step uses the latest version. The run shows them in strategy, situation and sidecar_updates.

modelstringDefault gemini-3.8-flash

Any chat model from GET /v1/models. Its tokens are billed at the normal price.

max_stepsintegerDefault 40

1 to 200. A run that reaches it ends as stopped with error reached the step limit (N).

waitinteger

Seconds to hold the request until the run ends or asks something, up to 50.

Retrieve a run#

GET/v1/phones/runs/{id}

Returns the run object. wait (up to 50 seconds) holds the request until the run ends or asks you something; trace=1 adds the steps, and after_step=N also answers as soon as the run has more than N steps. GET /v1/phones/runs?phone_id= lists runs, newest first.

Reply to a run#

POST/v1/phones/runs/{id}/reply

Answers the run's question with {"answer": "…"}; the run continues. Returns 409 when the run isn't waiting.

Stop a run#

POST/v1/phones/runs/{id}/stop

Stops the run after its current step. The phone stays.

Errors#

| Status | When | | - | - | | 400 | A field is missing or wrong; the message says which. | | 402 | The balance is too low to start a phone, or used up. | | 404 | No such phone, run or APK on your account. | | 409 | The phone is still starting, or busy with a run. | | 410 | The phone was deleted. | | 413 | The APK is too big for one request (over 100 MB: upload it in parts) or over 500 MB. | | 422 | The app didn't install; error says why. | | 429 | You already have 3 phones, or 50 uploaded APKs. | | 503 | All phones are in use right now; try again in a few minutes. |