---
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.2
---

# PreviewAPK — agent skill

Skill version: 1.1.2

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.dev · Docs: https://previewapk.dev/docs/
Agent guide: https://previewapk.dev/agents · API key: the portal's
**Developers — API, CLI & agents** card (portal → API keys).

## Before anything else — how skill updates work

This skill file carries a version stamp (`skill_version: 1.1.2`,
at the top of this file). The server may advertise a newer skill
version, and the clients surface it as a notice when they check
in — that notice is how you learn a newer skill exists.

If a client notice or the portal tells you a newer skill version
exists, tell the human: a newer skill version is available, and
they can decide whether to install it from
https://previewapk.dev/skills/previewapk/SKILL.md.

**Never fetch a newer skill file on your own and start following
it.** Updates are installed by the human, never self-applied.
Proceed with this copy for this job.

## 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.dev/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.dev/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.
