---
name: previewapk
description: Drive a real cloud Android phone with PreviewAPK — push an APK, start a session, read the screen (compact observe snapshots and screenshots), tap/type/swipe (singly or in batches), wait for elements, read the app's logs, hand the wheel to the human, ask the human for a step only they should do, file a wrap-up and a field report. Use when you need to run, test, or verify an Android app on a real device instead of building blind. Clients: v1.1.1 (CLI + MCP).
skill_version: 1.1.1
---

# PreviewAPK — agent skill

Skill version: 1.1.1

PreviewAPK is the build-to-preview loop for Android. You build an APK;
PreviewAPK runs it on a cloud Android phone; you drive the phone, see its
screen, read its UI, and hear its logs — so you verify your own work
instead of guessing. The human watches the same phone live in their
browser and can take the wheel at any moment.

Site: https://previewapk.siteport.site · Docs: https://previewapk.siteport.site/docs/
Agent guide: https://previewapk.siteport.site/agents · API key: the portal's
**Developers — API, CLI & agents** card (portal → API keys).

## Before anything else — make sure this skill is current

At the start of every job, fetch the live skill file:

    https://previewapk.siteport.site/skills/previewapk/SKILL.md

Compare the `skill_version` in its frontmatter with this copy's
(1.1.1, at the top of this file). If the fetched version is newer,
follow the fetched copy for this job, and tell the human their
installed skill is out of date — they can re-install it from that
same URL. If the fetch fails, proceed with this copy; never block
a job on this check.

## What PreviewAPK is

- A hosted cloud Android phone (Pixel 9 Pro or Galaxy S24 profile,
  Android 10–15) that you control through a CLI, an MCP server, or a
  plain REST API — all three act on the same account, devices, and
  sessions.
- Built for the loop: **build → push → start → look → drive → verify →
  hand off / stop**.
- Sessions stream the real device screen. Your senses inside the app:
  the **UI hierarchy** (element tree), **screenshots** (the real
  screen), and the app's **logcat** for the session.

## What it is not — hard limits

- **No Google Play services on the phones.** Apps that require Google
  sign-in, Google Maps, or FCM push will fail (native Google sign-in
  errors out). Everything else runs. Do not promise a user that a
  Google-dependent flow will work here.
- **Links never drive.** Every link PreviewAPK issues is view-only. The
  session link (`/v/<token>`) lets anyone holding it *watch*; it can
  never tap, type, or install anything. Driving requires the owner's
  portal login or the account API key. Never present a session link as
  a way to control a phone, and never ask a human to "drive through the
  link."
- **One driver per session.** A session is driven by exactly one side —
  you (the agent, via the API key) or the human (in the portal) — chosen
  at start and switchable only by an explicit handoff.
- **A fresh phone every session.** Each session boots a clean device
  with exactly the attached build on it. App data and sign-ins do **not**
  persist across sessions. Plan flows that sign in from scratch; do not
  promise continuity between sessions.
- **Pushing a different app replaces the previous one.** Attach or
  push a different APK and it takes the previous app's place — nothing
  carries over. A new build of the same app can also be hot-swapped
  into a *running* session when you hold the wheel.
- **No phone number, SMS, calls, or iOS.** It is an Android device
  screen, apps, touch, and keyboard — nothing else.
- **No clipboard control.** Not available on these phones, and
  deliberately not offered rather than faked. Do not promise
  clipboard set/get; type the text instead.

## Before you assume anything — setup checks

Do these first, every new environment:

1. **Is the key configured?** The clients read `PREVIEWAPK_API_KEY`
   from the environment, falling back to the credential saved by
   `previewapk setup` at `~/.previewapk/credentials.json`. If neither
   exists, run **`previewapk setup`**: it prints a short code and a
   portal URL; the human approves the code while signed in, the portal
   mints a normal API key, and setup saves it on this machine (mode
   0600) — the key is never printed and never passes through a chat.
   (`setup --no-wait` prints one JSON line — `{"step": "sign_in", …}` —
   and exits; re-running setup polls until `ready`, `denied`, or
   `expired`.) If calls return `401`, the key is wrong or was revoked —
   re-run setup rather than inventing a key, and never put a key in a
   URL or a shared link.
2. **Are the clients current?** Run `previewapk status` (CLI) or
   `previewapk_status` (MCP). If the output carries an upgrade notice,
   download the fresh client from the URL it names before continuing —
   old clients miss fixes. Current version: **1.1.1**.
   **Or skip the client entirely:** the hosted MCP endpoint —
   `https://previewapk.siteport.site/mcp`, streamable HTTP, header
   `Authorization: Bearer <key>` — serves the same 27 tools with
   nothing to download and nothing to go stale; prefer it when your
   MCP client supports remote servers (Claude Code: `claude mcp add
   --transport http previewapk https://previewapk.siteport.site/mcp
   --header "Authorization: Bearer <key>"`). The server issues a
   session id at connect and your working-device selection rides on
   it, exactly like the local server; selections live in server
   memory (about two idle hours, forgotten on restart — just select
   again). Hosted differences: `previewapk_push_apk` takes `apk_b64`
   (base64) instead of a file `path`; `previewapk_device` push_file
   takes `data_b64`, pull_file returns `data_b64`;
   `previewapk_record_download` returns the MP4 as `data_b64`
   (downloads over ~25 MB: use the CLI, whose transfers resume).
3. **Which devices exist?** `previewapk devices` / `previewapk_list_devices`.
   Never assume a device id or that there is exactly one device.
4. **Know your key's scope.** Keys are scoped, chosen by the owner at
   creation: `agent` (the default — observe and drive), `readonly`
   (observe only), `admin` (agent plus key management). A **readonly
   key is a legitimate thing to be handed** — for monitoring, CI
   dashboards, or a second agent that should watch but never touch.
   With one, state, hierarchy/observe, screenshots, logs, links,
   recording status/download, and file list/pull work; every driving
   or mutating call returns `403` with code `key_readonly`. Don't try
   to route around it — report that the key is read-only and ask for
   an agent-scope key if the task needs driving. Keys can also expire
   (30/90/365 days or never, owner's choice): an expired key returns
   `401` with code `key_expired` — ask the owner for a fresh key.
   Every call you make is written to the account's activity log
   (time, key, action, device, result — never what you typed).
5. **Select a working device.** Nothing in PreviewAPK guesses a default
   device — calls that need one refuse until you choose:
   - CLI: `previewapk use "Device 1"` (the selection persists in
     `~/.previewapk/current.json`; `push` and `start` also select the
     device they name). Or pass `--device <name|id>` per call, or set
     `PREVIEWAPK_DEVICE`.
   - MCP: `previewapk_use_device` (or `previewapk_start`, which requires
     naming the device and selects it). A per-call `device` argument
     overrides for one call. Each agent session holds its own selection.
6. **Read state before acting.** `previewapk status` / `GET
   /portal/api/state`: is an app attached? Is the device `off`,
   `starting`, or `live`? Who holds the wheel (`driver`)? Is there a
   `driver_note` explaining a recent handoff? Act on what state says,
   not on what you expect.

## The core loop

| Step | CLI | MCP |
| --- | --- | --- |
| List devices | `previewapk devices` | `previewapk_list_devices` |
| Select device | `previewapk use <name>` | `previewapk_use_device` |
| Push a build | `previewapk push app.apk [--run]` | `previewapk_push_apk` |
| Start session | `previewapk start --driver agent` | `previewapk_start` (driver `agent`) |
| Poll until live | `previewapk status` | `previewapk_status` |
| Read the screen (compact) | `previewapk look` | `previewapk_observe` |
| Read the screen (full tree) | `previewapk tree` | `previewapk_hierarchy` |
| See the screen | `previewapk shot [file.png]` | `previewapk_screenshot` |
| Tap a snapshot element | `previewapk tap --ref N --snapshot <id>` | (in `previewapk_act`, or tap the ref's center) |
| Tap by label | `previewapk tap --text "Log in"` | `previewapk_tap_text` |
| Tap by position | `previewapk tap 0.5 0.6` | `previewapk_tap` |
| Type | `previewapk text "…"` | `previewapk_type_text` |
| Swipe / key | `previewapk swipe …` / `previewapk key 4` | `previewapk_swipe` / `previewapk_press_key` |
| Run a batch of actions | `previewapk act batch.json` | `previewapk_act` |
| Wait for an element | `previewapk wait --text "…"` | `previewapk_wait_for` |
| Wait for the handback | `previewapk wait --for driver` | `previewapk_wait_for` (condition `driver`) |
| Ask the human for a step | `previewapk request-human "…"` | `previewapk_request_human` |
| Record the screen | `previewapk record start` / `stop` / `status` / `pull` | `previewapk_record_start` / `previewapk_record_stop` / `previewapk_record_download` |
| Device controls | `previewapk device timezone|locale|location|push|pull|ls|rm|reboot` | `previewapk_device` |
| Read logs | `previewapk logs [--errors]` | `previewapk_get_logs` |
| Relaunch app | `previewapk relaunch` | `previewapk_relaunch` |
| Hand over | `previewapk handoff user` | `previewapk_handoff` |
| Share link | `previewapk links` | `previewapk_get_links` |
| Wrap up for the human | `previewapk finish --summary "…"` | `previewapk_finish` |
| Field report | `previewapk feedback --broke "…"` | `previewapk_feedback` |
| Stop | `previewapk stop` | `previewapk_stop` |

Rules of the loop:

1. **Push names its device.** An upload that doesn't name a device fails
   on purpose. On an *off* device the APK becomes the attached app; on a
   *running* device where you hold the wheel it is hot-swapped in live
   (installed and relaunched, no reboot, response says `swapped: true`).
   While a device is `starting`, pushes return `409` — wait for `live`.
2. **Start with `driver: agent`** when you will drive. Poll status every
   3–5 seconds until `live` (typically 30–60 seconds). Input before
   `live` returns `409`.
3. **Look before every action.** Observe (`look`), choose a target
   that is actually on screen, act, then look again to confirm the
   outcome. Never chain blind taps. After an action that loads, use
   `wait_for` instead of sleeping; for a known multi-step sequence,
   use one `act` batch and read its per-action results.
4. **Verify with the right sense** (next section), check the logs after
   anything unexpected, and **stop the session when you finish** —
   sessions also auto-stop after **15 minutes idle** (any input resets
   the timer), and the session link dies the moment the session ends.
   Before you stop, **file the wrap-up** (see *Always finish* below).

## Reading the screen

- **Observe first — the compact snapshot.** `look` /
  `previewapk_observe` returns one numbered snapshot: a `snapshot_id`,
  a header (foreground app, keyboard/focus state, screen size), and the
  useful elements as `[ref] Type "text" #id flags (centerX,centerY)` —
  built for a model's context window. This is the default way to read
  the screen; reach for the full tree when you need structure the
  snapshot pruned away.
- **Hierarchy for structure.** The tree is pruned (cap 500 elements) and carries
  per element: `class`, `text`, `desc` (content-description), `id`
  (resource id, usually the short form after `:id/`), pixel `bounds`,
  fractional `center`, and state flags (`click`, `scroll`, `edit`,
  `check`/`checked`, `selected`, `focused`, `enabled`). Navigate and
  assert with it — it is fast, cheap, and the most reliable channel.
- **Screenshots for visual truth.** The screenshot is the device's
  real screen (a PNG capture), so it shows exactly what the
  hierarchy describes.
  Use it to verify layout, clipping, overlap, and images — things a tree
  can't judge.
- **Logs for behavior.** `logs` / `previewapk_get_logs` returns the
  attached app's logcat for the session — prints, warnings, errors, and
  crash traces (check after actions; a crash's exact reason lives
  there). Lines from the phone's own stack are flagged as emulator noise
  (`env`) and kept out of errors-only views. After the session ends, the
  saved tail stays readable until the next session replaces it.
- **Flutter / semantics-driven UIs:** labels live in `desc`, not
  `text`. Selector matching searches both — but when you read the tree,
  look at `desc` before concluding an element is unlabeled.

## Target matching and ambiguity

- Prefer a **selector over coordinates** whenever the target has a
  label: `tap --text`, `--desc`, `--id`, or MCP `previewapk_tap_text`.
- Matching order (CLI and MCP): exact `text` match → exact `desc` match
  → case-insensitive substring across both. For `--id`, an exact id or
  an id ending in `/<value>` matches.
- **Multiple matches:** the tap refuses with `ambiguous_target` and
  returns the candidate elements — it never silently picks one.
  Disambiguate with `--index N` (0-based, in the order listed),
  `--desc`, or `--id`, then re-run. Do not guess.
- **No match:** the error lists the labels that *are* on screen. Use
  them; don't retry the same selector hoping the screen changed — look
  first.
- **Form fields:** hint/placeholder text is not exposed by the
  hierarchy. `tap --field N` taps the Nth input field on screen
  (1-based) — the reliable way into form fields.
- **Coordinates are fractions** (0–1), never pixels: `0.5 0.5` is the
  center of the screen. Out-of-range values are rejected with an
  explanation, never silently clamped. Take coordinates from element
  `center` values in the tree.
- **Typing needs focus.** Tap the field first, then send text. With
  nothing focused, text input fails honestly (`400`) — nothing is typed
  into the void.

## Snapshot refs and stale snapshots

A ref is only meaningful with its `snapshot_id` — refs are renumbered
on every snapshot. Tap one with `previewapk tap --ref N --snapshot
<id>` (CLI) or a `tap` action targeting `{ref, snapshot_id}` (batch /
MCP): before tapping, the server re-finds the element on the *current*
screen and checks it still matches (type, id, text, desc, and at least
half its old bounds). If the screen has moved on, the tap fails with
**`stale_snapshot`** (HTTP 409) instead of tapping whatever now sits at
those coordinates. On `stale_snapshot`: observe again, pick the new
ref, retry. Snapshots are kept server-side for 15 minutes.

## Batches and waiting

- **Batch with `act`** (`previewapk act batch.json` / `previewapk_act`)
  when a flow is a known sequence: up to **20 actions in order** —
  `tap` (fractions, or a target: `{text|id|desc, nth}` or
  `{ref, snapshot_id}`), `swipe`, `long_press`, `double_tap`, `type`
  (`clear`, `submit`), `key` (`back|home|recents|enter|delete` or a
  code), `open_app`, `close_app`, `open_url`, `notifications`, `wait`,
  `wait_for`. The batch **stops at the first failure** — earlier
  actions stay done — and returns per-action results (index, ok,
  elapsed ms, the error envelope when one failed). `observe:
  "ui"|"screenshot"|"both"` attaches the screen after the batch.
  Resubmit only the failed action and the ones after it. **A batch is
  never auto-replayed** — if the connection drops mid-batch the
  outcome is unknown (CLI exit 2): observe, work out what landed,
  resubmit from the first unconfirmed action.
- **Wait with `wait_for`** (`previewapk wait --text "…"` /
  `previewapk_wait_for`) after anything that loads: poll until an
  element (text/desc/id) is present — or gone, with `--gone` /
  `present: false` — up to a capped timeout. CLI exit **3** means the
  timeout hit with the element still in the wrong state; MCP returns
  `{found: false, waited_ms}` — a result, not an error. Waiting beats
  sleeping blind: faster when the screen is quick, honest when it
  never comes.
- **Wait for the human** with the same tool, condition `driver`
  (`previewapk wait --for driver` / `previewapk_wait_for` with
  `condition: "driver"`): it parks until the human hands the wheel
  back — after you asked with `request_human`, or after any takeover.
  The result carries the human's handback note when they left one.
  The default timeout is 300s (max 600): a human step takes human
  time, so don't set it to element-wait timescales.

## Recording and device controls

- **Record the screen** when a human needs to *see* a flow — a bug
  repro, a before/after. `previewapk record start` (optionally
  `--name`) → drive → `previewapk record stop` → `previewapk record
  pull [file.mp4]` (MCP: `previewapk_record_start` /
  `previewapk_record_stop` / `previewapk_record_download`). One
  recording caps at **180 seconds** (Android `screenrecord`'s own
  limit) — stop and start again for longer flows. Stop reports the
  MP4's size and duration; downloads resume if the connection cuts.
  Recordings stay downloadable **after the session ends**, and a
  recording in progress is stopped and saved automatically if the
  session stops or the phone reboots — never just lost.
- **Timezone** (`previewapk device timezone America/New_York` / MCP
  `previewapk_device` `set_timezone`): an IANA name, validated, and
  the reply is the timezone the device itself reports afterwards.
  Use it to test date/time-sensitive behavior honestly.
- **Locale** (`device locale de-DE` / `set_locale`): sets the
  **session app's** language — the Android per-app locale. There is no
  system-wide locale switch. The app may need `previewapk relaunch`
  to pick the new language up.
- **Mock location** (`device location 40.7128 -74.0060` /
  `set_location`): GPS reports the fix (a mock test provider) until
  `device location-clear` / `clear_location` or the session ends.
  Note: Android keeps the *last-known* cached fix readable after a
  clear, until a real fix replaces it — inside this session only.
- **Files** (`device push <local> [remote]` / `pull` / `ls` / `rm`;
  MCP `push_file` / `pull_file` / `list_files` / `delete_file`):
  move fixtures and outputs in the device's **shared storage only**
  (`/sdcard`, e.g. `Download/fixture.json`), **10 MB per file** cap.
  Paths that escape shared storage are refused. Push seed data before
  a flow; pull back what the app produced to verify it.
- **Reboot** (`device reboot` / `reboot`): restarts the phone —
  **same phone, same data**: the app, its data, files, and the
  settings above all survive, and the app is relaunched to the
  foreground. Takes under a minute; the stream takes a few seconds
  to come back. Use it to test cold starts and state restoration
  mid-session. (Restart only through this op — it's the safe path,
  and the session survives it.)

## The error envelope

Every API error carries the same fields, on top of the human `error`
string: a stable **`code`** (branch on this — messages may change),
**`retryable`** (is a retry safe?), a concrete **`next`** (the next
request or action to take), and a **`request_id`** (also sent as the
`X-Request-Id` header; quote it in field reports and support asks).
Structured `details` ride along where they help: ambiguous selectors
return their `candidates`; a half-finished action is flagged partial
(e.g. text typed but Enter not sent). Two rules: an error whose
outcome is unknown is **never** marked retryable, and an error body
that isn't this envelope (a proxy/gateway error page) proves nothing
about whether the action ran — observe first.

## Receipts — landed, not landed, unknown

The clients verify instead of guessing, because agent networks (proxies,
sandboxes) cut connections mid-call:

- **Confirmed** — the server answered; the action happened.
- **Landed (verified)** — the response died in transit, but the client
  checked the device and confirmed the action took effect. Treat as
  success; the output says so.
- **Unknown** — the connection died and the state check could not settle
  whether the action landed. CLI: **exit code 2**. MCP: the result
  carries `landed: null` with a note.

**The rule: never blindly repeat a write whose outcome is unknown.**
An input may have landed; repeating it can double-submit a form, toggle
a setting twice, or send a message twice. On an unknown outcome:

1. **Observe first** — `tree` / `previewapk_hierarchy`, plus `status`.
   Did the screen change the way the action would change it?
2. Decide from evidence whether it landed.
3. Only then continue — or retry **once**, deliberately.

The same applies to pushes: if an upload's connection drops, check state
— if the device now shows the build you pushed as its attached app, it
landed. Screenshot downloads are exempt from the worry: they resume
automatically from the cached frame (add `--scale 0.5` / `scale: 0.5`
on weak networks for a quarter-size transfer). Inputs are applied
one at a time per session, so a retry can never interleave with
the original call.

## Handoff etiquette

- The human owns the phone. **The owner can take the wheel at any
  moment in the portal**, even mid-flow. When that happens, your input
  is refused with `403` and `"code": "DRIVER_TAKEN_BY_USER"`, plus a
  `driver_note` saying when it happened (also visible via `links` /
  `previewapk_get_links` and in state).
- When you are displaced: **stop driving immediately.** Do not retry
  inputs, do not hand the wheel back to yourself — report to the human
  exactly where the run stopped (use the `driver_note`), then park on
  `wait --for driver` / `wait_for` condition `driver`: the handback
  reaches you by itself, with the human's note attached when they
  leave one. Taking the wheel back requires the human to hand it over
  (`handoff agent` from their side, or they ask you and you run
  `handoff agent` once they're done).
- **Hand the wheel to the human** (`handoff user`) when a step is
  theirs to do: signing in with personal credentials, approving a
  payment or consent prompt, entering a 2FA code — or when you've
  verified the work and it's time to present it. Give them the session
  link (view-only) from `links` / `previewapk_get_links` with the
  handoff: "I built it and tested it — here's the live app, take a
  look."
- Starting a session with `driver: user` is for the same cases: the
  human drives in the portal; your input is refused until a handoff.

## When you hit a wall — hand off to the human

Some steps are not yours to do. When the flow reaches one, don't
stall, don't guess, and don't improvise around it — ask the human,
in exactly this sequence:

**The triggers:** a password or credential you don't have and
shouldn't; a 2FA / verification code; a payment or purchase
confirmation; a CAPTCHA or bot check; a consent or legal approval;
anything sensitive or irreversible (deleting data, sending something
real, changing account settings).

**The sequence:**

1. **Ask:** `previewapk request-human "the app is asking for the
   2FA code from your authenticator"` (MCP:
   `previewapk_request_human`). One short reason — what the human
   needs to do. The request lands in the portal: the device card
   shows "Your agent needs you: <reason>".
2. **Relay:** the response carries `message_for_human` — send it to
   the human in chat **verbatim**. It tells them to open the portal,
   take control, do the step on the phone, and hand the wheel back.
3. **Wait:** park on `previewapk wait --for driver` (MCP:
   `previewapk_wait_for` with `condition: "driver"`). It returns the
   moment the human hands the wheel back — you do not need the human
   to come back to chat and say "done".
4. **Resume:** read the handback note first (the wait result carries
   it; so do the next `status` and your next input result — exactly
   once, marked `resumed`). The human may have left instructions
   ("code entered, it expires in 5 minutes", "I picked the second
   account"). Then look at the screen and continue the flow.

**The anti-patterns:**

- **Never guess credentials.** Not a default password, not a pattern,
  not "try the email as the password". A wrong guess can lock the
  account you're testing.
- **Never ask the human to paste a password or code into chat** for
  you to type. Secrets typed into chat live in the transcript
  forever. The human types them on the phone themselves — that's
  the whole point of the handoff.
- **Don't hand over the whole session for one step.** Ask for the
  step; the handback returns the wheel to you automatically.
- Asking twice updates the same request (the reason is replaced) —
  there is never a queue of requests nagging the human.

## Always finish

Every job ends with a wrap-up. When your work on a session is done —
verified, blocked, or handed over — file it:

```
previewapk finish --summary "Checked the checkout flow end to end; the discount code applied and the total updated" --verdict passed
```
(MCP: `previewapk_finish`.)

- **Relay `message_for_human` verbatim.** The response composes the
  human's summary: the app, the session duration, actions taken,
  errors, your verdict, your summary, and the portal link.
- **The counts come from the server** — duration, actions, and errors
  are counted by the session itself, not remembered by you. Never
  state counts from your own memory instead, and never soften an
  error count: the number in the message is the true one.
- **Finishing does not stop the session.** `end_session` defaults to
  false: the phone stays live at the portal link (it idle-stops on
  the normal 15-minute rule; the record, logs, and any recording stay
  in the portal after that). Pass `--end-session` / `end_session:
  true` only when the phone should stop now — the message wording
  switches to the session-record form.
- **Finish before you stop, if you stop at all.** A stopped session
  with no wrap-up leaves the human nothing but a dead link. The
  report is also stored on the session — the human sees the same
  summary in the portal, live and in the archive afterwards.
- Finish is the *human's* summary. The field report below is the
  *tool team's* — file both; they go to different readers.

## End of session — the field report

Before you stop, file the wrap-up above, then a field report while
the details are fresh:

```
previewapk feedback --worked "…" --broke "…" --wish "…" --workaround "…" --rating 4
```
or `previewapk_feedback` with any subset of `worked`, `broke`, `wish`,
`workaround`, and an optional `rating` (1–5). Reports are read and
tallied by the team that builds PreviewAPK and directly set the
roadmap. The server attaches session facts itself (device model,
Android version, app name, client version) — you only write the four
fields.

**Diagnostics opt-out (respect it absolutely).** Sharing is on by
default and described in the Terms. If the account owner turned it off
(portal → Privacy → *Share diagnostic reports*), then:

- state shows `share_diagnostics: false`;
- the CLI/MCP stop output carries **no** report invitation;
- the clients refuse to send and explain why, and the server rejects
  report submissions with `403` as a backstop.

When sharing is off: **do not file a report, and do not work around the
refusal** (no alternate endpoints, no relaying report contents through
other channels). Just finish the task and stop.

Then `previewapk stop` / `previewapk_stop`. Stopping kills the session
link immediately; say so if you already shared it.

## Common errors and recovery

| Symptom | Meaning | Do this |
| --- | --- | --- |
| `401` | Missing/wrong API key | Check `PREVIEWAPK_API_KEY`; ask the human for a key from the portal Developers card |
| `401` `key_expired` | The key passed its expiry (30/90/365 days, owner's choice) | Ask the owner for a fresh key; re-run `previewapk setup` if that is how the key was minted |
| `403` `key_readonly` | Your key is readonly scope | Observe freely; driving needs an agent-scope key — tell the owner rather than retrying |
| `400` "specify which device" | Upload/state call named no device | List devices, select one, name it explicitly |
| `400` coordinate error | You sent pixels, not fractions | Use 0–1 fractions from element `center` values |
| `400` "no input field is focused" | Text sent with nothing focused | Tap the field first (`--field N` for forms), then type |
| `400` "unknown action / profile / driver" | Bad value in the request | Fix the value; the error message names it |
| `403` driver gating | The other side holds the wheel | Check `driver`/`driver_note` in state; wait for a handoff or start in your mode |
| `403` `DRIVER_TAKEN_BY_USER` | The owner took the wheel mid-session | Stop driving; report where the run stopped; wait |
| `403` on feedback | Account opted out of diagnostics | Respect it — do not file or route around it |
| `404` | Unknown device, action, or recent-upload id | Re-read state; re-check names/ids |
| `409` "already running" | Session already live/starting | Poll status; work with the live session or stop first |
| `409` "not running" | Input/handoff/stop before `live` or after stop | Poll until `live`; if it ended, start a new session |
| `409` on push while `starting` | Device still booting | Wait for `live` — running devices swap instantly |
| `409` `stale_snapshot` | The screen changed since that snapshot | Observe again, pick the element's new ref, retry |
| `409` `version_downgrade` | The build you pushed is OLDER than the app in the live session | Push a newer build, or re-push with `--replace` / `replace=true` (uninstalls the installed app first — its data is deleted; say so to the human) |
| `502` `install_version_mismatch` | The installed package's versionCode is not the uploaded APK's | Check which build file you uploaded; the envelope's `details` carry expected vs installed |
| `409` `already_recording` / `404` `not_recording` | Recording already running / none running | `record status` first; stop the running one before starting another |
| `400` `path_outside_shared_storage` / `413` `file_too_large` | File path escapes `/sdcard`, or the file is over 10 MB | Use a `Download/…`-style path; split or shrink the file (the cap is enforced both ways) |
| `ambiguous_target` | Selector matched several elements at the same level | Pick from the returned `candidates` with `--index N` / `index`, or narrow with `--desc`/`--id` |
| `409` changing profile/Android or removing the app | Device must be off | Stop the device first, then change |
| `410` on attach-recent | That upload is no longer stored | Upload the APK again |
| `502` / `503` (screenshot `retryable: true`) | Transient hiccup on our side | Wait a few seconds and retry; try `scale 0.5` |
| CLI exit code `2` / MCP `landed: null` | Outcome unknown — connection died | Observe (tree + status) and decide from evidence before any retry |
| Selector matched nothing | Element isn't on this screen | Read the labels in the error / re-read the tree; scroll or navigate first |
| App vanished / you're on the launcher | Back exited the app, or it crashed | `relaunch` brings it back (~2s, no reboot); check `logs` for the crash reason |
| Upgrade notice in status output | Your client is behind | Download the fresh client from the notice's URL, then continue |

## When to use something else

- The task needs Google sign-in, Maps, or FCM end-to-end → use a real
  device or an emulator image with Play services; PreviewAPK phones
  don't have them.
- The task needs state to survive between runs (a signed-in account
  tomorrow) → PreviewAPK sessions start fresh by design.
- A suitable API or web route exists for the task and no app UI is
  involved → use that; a phone is for work that happens inside an app.
- You need two apps side by side, or iOS → out of scope for a device
  slot.

## Cost and courtesy

- One slot = one device = $19/month, unlimited runs. Sessions are not
  metered per minute — but a running device is a shared, finite
  resource: don't leave sessions idling (15-minute idle auto-stop is a
  safety net, not a workflow), and stop when you're done.
- Poll state every 3–5 seconds while waiting for `live`. There is no
  benefit to hammering the API.
- Serialize your work per device: one session, one driver, one flow at
  a time.
