# AkaPhone documentation (full text) Generated from https://akaphone-docs.pages.dev. Index: https://akaphone-docs.pages.dev/llms.txt # AkaPhone documentation > Cloud Android phones for AI agents and people. Create a device, tell it what to do in plain English, and watch it work. Source: https://akaphone-docs.pages.dev/ AkaPhone runs real Android 14 systems in the cloud and hands them to you, or to your coding agent, as a tool. Each device is a full Android phone in its own container. You can watch its screen live in the browser, tap and type on it, install your own APKs, save its state as a snapshot, and give it plain-English tasks such as *“Open Settings and turn on dark mode”*. Tasks are carried out by [ARTEMIS](https://github.com/google/artemis), Google's open-source Android agent, running unmodified on the device host. It uses **your own** Gemini, Claude or OpenAI key. AkaPhone bills device time, not tokens. ## Three ways to use it - [Claude Code, Cursor, Codex](https://akaphone-docs.pages.dev/quickstart/claude-code/): Add the MCP server and ask your agent to create a phone and test your app. - [In the browser](https://akaphone-docs.pages.dev/quickstart/dashboard/): Sign in with Google, create a device, watch it live and drag in an APK. - [From code and CI](https://akaphone-docs.pages.dev/api/rest/): Plain JSON over HTTPS with a Bearer key. Works from curl, scripts and GitHub Actions. ## Five-minute tour 1. **Sign in** at [https://akaphone.abhgnx-417.workers.dev](https://akaphone.abhgnx-417.workers.dev) with Google. New accounts get **60 free credit minutes**, which is 60 minutes of one running device. No card needed. 2. **Create an API key** on the *API keys* page. It starts with `ak_live_` and is shown once. 3. **Connect your agent.** For Claude Code that is one command: ```bash claude mcp add akaphone \ -e AKAPHONE_API_KEY=ak_live_... \ -e MODEL_API_KEY=AIza... \ -- npx -y akaphone-mcp ``` 4. **Ask for something.** *“Create a phone, open Settings > About phone and tell me the Android version. Then terminate it.”* 5. **Watch it** in the dashboard while it runs, and check *Usage* afterwards to see the minutes it used. Or skip the agent and do the same thing over HTTP: ```bash title="curl" curl -X POST https://akaphone.abhgnx-417.workers.dev/v1/devices \ -H "Authorization: Bearer $AKAPHONE_API_KEY" \ -H "Content-Type: application/json" \ -d '{"name": "first-phone", "wait": true}' ``` ```typescript title="TypeScript" const res = await fetch("https://akaphone.abhgnx-417.workers.dev/v1/devices", { method: "POST", headers: { Authorization: `Bearer ${process.env.AKAPHONE_API_KEY}`, "Content-Type": "application/json", }, body: JSON.stringify({ name: "first-phone", wait: true }), }); const device = await res.json(); console.log(device.id, device.status); // dev_s6zr7mr4f2f1 running ``` ```python title="Python" import os, requests r = requests.post( "https://akaphone.abhgnx-417.workers.dev/v1/devices", headers={"Authorization": f"Bearer {os.environ['AKAPHONE_API_KEY']}"}, json={"name": "first-phone", "wait": True}, ) device = r.json() print(device["id"], device["status"]) # dev_s6zr7mr4f2f1 running ``` > **Devices bill until you stop them** > > A running device costs 1 credit per started minute. Stop it with `DELETE /v1/devices/:id` (or *Stop* in the dashboard) when you are done. Devices with no activity for 30 minutes are stopped for you. ## What you get | | | |---|---| | **Devices** | Android 14 (`pixel_8` profile, 720×1560) in an isolated container. Up to 1, 3 or 5 at once depending on your plan. [Devices →](https://akaphone-docs.pages.dev/concepts/devices/) | | **Tasks** | Plain-English instructions carried out by ARTEMIS with your model key, with step-by-step progress, a screen recording, before/after screenshots, a trace and a log. [Tasks →](https://akaphone-docs.pages.dev/concepts/tasks/) | | **Live view** | The screen streamed to your browser at 2–5 fps, with tap, swipe, type and hardware keys. [Streaming →](https://akaphone-docs.pages.dev/concepts/streaming/) | | **APKs** | Drag an APK onto the device in the dashboard, or upload it over the API. Up to 300 MB. [Install an APK →](https://akaphone-docs.pages.dev/guides/install-apk/) | | **Snapshots** | Save a configured device (apps, logins, settings) and boot new devices from it. [Snapshots →](https://akaphone-docs.pages.dev/concepts/snapshots/) | | **Credits** | Prepaid packs from $10. One credit is one device-minute. No subscription. [Credits and plans →](https://akaphone-docs.pages.dev/concepts/credits-and-plans/) | ## For agents This site is also published as plain text for language models: [/llms.txt](https://akaphone-docs.pages.dev/llms.txt) is an index of every page with a one-line summary, and [/llms-full.txt](https://akaphone-docs.pages.dev/llms-full.txt) is the whole site in one file. Every page is available as Markdown by adding `.md` to its path, for example [/api/devices.md](https://akaphone-docs.pages.dev/api/devices.md). --- # Quickstart with Claude Code > Give Claude Code a cloud Android phone in two minutes with the AkaPhone MCP server. Source: https://akaphone-docs.pages.dev/quickstart/claude-code/ The AkaPhone MCP server (`akaphone-mcp` on npm) runs locally next to Claude Code over stdio and turns AkaPhone into 13 tools: create a device, run a task on it, install an APK, tap and type, take a screenshot, snapshot it, and so on. Claude decides when to call them. You need Node.js 20 or newer (for `npx`) and Claude Code. ## 1. Get an API key 1. Open [https://akaphone.abhgnx-417.workers.dev](https://akaphone.abhgnx-417.workers.dev) and sign in with Google. New accounts start with 60 free credit minutes. 2. Go to **API keys** and click **Create key**. Give it a name such as `laptop-claude-code`. 3. Copy the secret. It looks like `ak_live_urQuj41lKvo2VYp2kBoFqRGw5ig661RoAtM5uTvyrhB` and is **shown only once**. If you lose it, revoke it and create another. ## 2. Get a model key Tasks are run by the ARTEMIS agent on the device host, and ARTEMIS calls a model with **your** key. The MCP server sends it with each task as the `X-Model-Key` header. AkaPhone never stores it. See [Model keys](https://akaphone-docs.pages.dev/guides/model-keys/). The default provider is Gemini, so a [Google AI Studio](https://aistudio.google.com/apikey) key (`AIza…`) is the quickest start. To use Claude or OpenAI instead, also set `MODEL_PROVIDER` (below). ## 3. Add the server ```bash claude mcp add akaphone \ -e AKAPHONE_API_KEY=ak_live_... \ -e MODEL_API_KEY=AIza... \ -- npx -y akaphone-mcp ``` That registers a stdio server named `akaphone` for the current project. Add `--scope user` after `add` to make it available in every project. | Variable | Required | What it does | |---|---|---| | `AKAPHONE_API_KEY` | yes | Your `ak_live_…` key. The server exits at startup without it. | | `AKAPHONE_API_URL` | no | Defaults to `https://akaphone.abhgnx-417.workers.dev`. | | `MODEL_API_KEY` | for tasks | Your model provider key, sent per task as `X-Model-Key`. Without it, `device_run_task` has no model to use. | | `MODEL_PROVIDER` | no | `gemini` (default), `anthropic` or `openai`. Sent as `X-Model-Provider`. Any other value stops the server at startup. | Check it is connected: run `claude mcp list` in a terminal, or type `/mcp` inside Claude Code. The `akaphone` server should be listed as connected. ## 4. First prompts to try Start a new Claude Code session and paste one of these: > Create a phone, open Settings > About phone and tell me the Android version and build number. Then terminate the phone. > Create a phone called "checkout-test". Open Chrome, go to example.com and take a screenshot. Leave the phone running. > List my phones and how many minutes I have used this month. > Install ./app/build/outputs/apk/debug/app-debug.apk on checkout-test, open the app and tell me what the first screen says. > On checkout-test, save a snapshot called "chrome-ready", then terminate it. ## What happens For the first prompt, Claude typically makes three tool calls: 1. **`device_create`** boots a new Android 14 device and waits until it is `running` (up to 90 seconds; usually much faster when a slot is warm). Billing starts when the device is ready, at 1 credit per started minute. 2. **`device_run_task`** with an instruction like *“Open Settings > About phone and read the Android version and build number”*. ARTEMIS drives the phone step by step. The tool polls every 3 seconds and returns the finished task, including `result` (the agent's answer), `status` and `step_count`. While it runs, you can watch the screen live: open the device in the dashboard. 3. **`device_terminate`** stops the device. Billing stops with it. Every tool and its arguments are listed in the [MCP tools reference](https://akaphone-docs.pages.dev/mcp/tools/). > **Leave a phone running between prompts** > > Creating a device each time is simple, but if you are iterating on an app, keep one device and tell Claude its name or id. A device stops by itself after 30 minutes with no activity, so a forgotten device costs at most about half an hour. ## Troubleshooting **The server does not show up in `/mcp`, or shows as failed.** Run `npx -y akaphone-mcp` in a terminal with `AKAPHONE_API_KEY` set. If it prints `AKAPHONE_API_KEY is required`, the `-e` flag did not reach the server: remove it with `claude mcp remove akaphone` and add it again, keeping `--` before `npx`. **`AkaPhone API 401` or `unauthorized`.** The key is wrong or revoked. Keys start with `ak_live_`. Check the key list in the dashboard; revoked keys disappear from it. **Requests fail to connect or return unexpected 404s.** Check you are on `akaphone-mcp` 1.0.0 or later (`npx -y akaphone-mcp` fetches the latest). Versions before 1.0.0 defaulted to an older API host; with those, set `-e AKAPHONE_API_URL=https://akaphone.abhgnx-417.workers.dev`. If you set `AKAPHONE_API_URL` yourself, it must be exactly `https://akaphone.abhgnx-417.workers.dev`, with no path. **`402 insufficient_credits`.** Your balance is below 1 credit. Buy a pack on the [Billing](https://akaphone-docs.pages.dev/guides/billing/) page. **`409 concurrency_limit`.** You already have as many devices as your plan allows (1 on Free). Ask Claude to list and terminate devices you no longer need, or stop them in the dashboard. **The task fails straight away, or the result mentions an API key.** `MODEL_API_KEY` is missing, invalid, or for a different provider than `MODEL_PROVIDER`. If the server will not start and its log says `MODEL_PROVIDER must be one of gemini, anthropic, openai`, fix the variable and add the server again. **`409 conflict: Device already has an active task`.** One task runs per device at a time. Wait for the current one or cancel it in the dashboard. **`device_create` returns a device that is still `queued` or `creating`.** The 90-second wait ran out because no slot was free right away; the tool result says so. Ask Claude to check the device again in a minute. A device that cannot be placed within 10 minutes ends in `error` with `no_capacity` and is never billed. --- # Quickstart with the dashboard > Sign in with Google, start a phone, watch it live, run a plain-English task and drag in an APK, all from the browser. Source: https://akaphone-docs.pages.dev/quickstart/dashboard/ The dashboard at [https://akaphone.abhgnx-417.workers.dev](https://akaphone.abhgnx-417.workers.dev) does everything the API does, with a live screen. You do not need to install anything. ## 1. Sign in Open [https://akaphone.abhgnx-417.workers.dev](https://akaphone.abhgnx-417.workers.dev) and choose **Continue with Google**. Google sign-in is the only sign-in method. The first time you sign in, your account is created with **60 free credit minutes** and the Free plan (one device at a time). Your session lasts 30 days. **Sign out** in the account menu ends it. ## 2. Start a device 1. On **Devices**, click **New device**. 2. Optionally give it a name, such as `onboarding-test`. 3. Leave the profile on **Pixel 8** and Android on **14**. 4. Click **Start device**. The device appears as **Starting device** and then **Live**, usually within seconds when a warm slot is available. If every slot is busy it shows **Waiting for capacity** until one frees up (up to 10 minutes; waiting is free). Billing starts when the device is running: 1 credit per started minute. The balance in the sidebar updates as you go. ## 3. Watch and control it Open the device to see its screen, streamed live at 2 to 5 frames per second depending on your plan. | You do | The phone gets | |---|---| | Click | a tap at that point | | Click and drag, or scroll | a swipe | | Type | text input | | Enter, Backspace, Escape | `ENTER`, `DEL`, `BACK` | | The Back and Home buttons under the screen | `BACK`, `HOME` | While the screen is open the device counts as active, so it will not be stopped for being idle. ## 4. Run a task In **Run a task**: 1. Choose a **Model provider** (Gemini, Anthropic or OpenAI) and paste your **Model API key**. The key is kept in this browser's local storage and sent with each task; AkaPhone never stores it. **Forget key** removes it from the browser. See [Model keys](https://akaphone-docs.pages.dev/guides/model-keys/). 2. Pick **Flash** for short, simple actions or **Pro** for multi-step work. 3. Describe the task in plain English, for example: > Open Settings, go to Display, turn on Dark theme, then go back to the home screen. 4. Click **Run**. Steps appear as the agent works, and the screen shows what it is doing. When the task ends you get a status (**Passed** or **Failed**), the agent's result, and **Artifacts**: a screen recording, before and after screenshots, an ARTEMIS trace and a log. Download links are valid for 15 minutes; open the task again for fresh ones. Only one task runs on a device at a time. **Cancel task** stops the current one. ## 5. Install an APK Drag an `.apk` file anywhere onto the device page while it is running. The dashboard: 1. uploads it straight to storage (you see a progress bar), 2. verifies the upload, 3. installs it on the device with runtime permissions granted, 4. shows **Installed com.example.app** when the device reports back. Files can be up to 300 MB. The [Install an APK guide](https://akaphone-docs.pages.dev/guides/install-apk/) covers the same flow over the API. ## 6. Snapshot and stop - **Snapshot** saves the device's disk (apps, logins, settings). Start new devices from it on **Snapshots** or with **Start from snapshot** in the new-device sheet. See [Snapshots](https://akaphone-docs.pages.dev/concepts/snapshots/). - **Stop** ends the device and its billing. A device you forget is stopped after 30 minutes without activity. ## Elsewhere in the dashboard - **Tasks**: every task across your devices, with filters, steps and artifacts. - **API keys**: create and revoke `ak_live_…` keys for the [MCP server](https://akaphone-docs.pages.dev/quickstart/claude-code/) and the [REST API](https://akaphone-docs.pages.dev/api/rest/). - **Usage**: minutes per day and per device. - **Billing**: balance, credit packs and the credit ledger. See [Billing](https://akaphone-docs.pages.dev/guides/billing/). --- # Quickstart with Cursor and Codex > Configuration snippets for adding the AkaPhone MCP server to Cursor, OpenAI Codex and other MCP clients. Source: https://akaphone-docs.pages.dev/quickstart/cursor-and-codex/ The same `akaphone-mcp` server (version 1.0.0 or later) works in any MCP client that can launch a stdio server. It needs Node.js 20 or newer. Get a `ak_live_…` key from **API keys** in the [dashboard](https://akaphone.abhgnx-417.workers.dev) first, and a model key for tasks (see [Model keys](https://akaphone-docs.pages.dev/guides/model-keys/)). ## Cursor Add the server to `~/.cursor/mcp.json` (all projects) or `.cursor/mcp.json` in a project: ```json title="~/.cursor/mcp.json" { "mcpServers": { "akaphone": { "command": "npx", "args": ["-y", "akaphone-mcp"], "env": { "AKAPHONE_API_KEY": "ak_live_...", "MODEL_API_KEY": "AIza..." } } } } ``` Reload Cursor, then check the server is enabled in Cursor's MCP settings. Ask the agent: *“Create a phone and tell me what Android version it runs.”* > **Do not commit keys** > > If you use a project-level `.cursor/mcp.json`, add it to `.gitignore`. It contains your AkaPhone key and your model key. ## Codex Add the server to `~/.codex/config.toml`: ```toml title="~/.codex/config.toml" [mcp_servers.akaphone] command = "npx" args = ["-y", "akaphone-mcp"] [mcp_servers.akaphone.env] AKAPHONE_API_KEY = "ak_live_..." MODEL_API_KEY = "AIza..." ``` Start a new Codex session; the AkaPhone tools are available to it. ## Any other MCP client Run `npx -y akaphone-mcp` as a stdio server with these environment variables: | Variable | Required | Notes | |---|---|---| | `AKAPHONE_API_KEY` | yes | Your `ak_live_…` key. | | `AKAPHONE_API_URL` | no | Defaults to `https://akaphone.abhgnx-417.workers.dev`. | | `MODEL_API_KEY` | for tasks | Sent with each task as `X-Model-Key`; never stored by AkaPhone. | | `MODEL_PROVIDER` | no | `gemini` (default), `anthropic` or `openai`. Any other value stops the server at startup with a clear error. | ## Next - [MCP tools reference](https://akaphone-docs.pages.dev/mcp/tools/): every tool, its arguments and what it returns. - [Tasks](https://akaphone-docs.pages.dev/concepts/tasks/): how ARTEMIS runs a task, and choosing `flash` or `pro`. - [Claude Code quickstart](https://akaphone-docs.pages.dev/quickstart/claude-code/#troubleshooting): troubleshooting applies to every client. --- # Devices > What a AkaPhone device is, its lifecycle from queued to terminated, profiles, idle stop, and what counts toward your concurrency limit. Source: https://akaphone-docs.pages.dev/concepts/devices/ A **device** is one Android 14 system running in its own container on a AkaPhone host. It behaves like a phone: it has a screen you can watch, it accepts taps and typing, it can install APKs, and an agent can drive it. Devices are created on demand and destroyed when you stop them. Nothing carries over between devices unless you use a [snapshot](https://akaphone-docs.pages.dev/concepts/snapshots/). ## Lifecycle ```text queued ──▶ creating ──▶ running ──▶ stopping ──▶ terminated │ │ │ │ └──▶ error └──▶ lost └──▶ error (no_capacity after 10 min) / terminated (stopped while queued) ``` | Status | Meaning | Billed | |---|---|---| | `queued` | Accepted, waiting for a free slot on a host. | No | | `creating` | Booting on a host. | No | | `running` | Ready. You can stream, send input, install apps and run tasks. | Yes, per started minute | | `stopping` | A stop was requested; the host is shutting it down. | No | | `terminated` | Gone. Final. | No | | `error` | Could not be created. `error` says why, for example `no_capacity`. Final. | No | | `lost` | The host holding it stopped responding. Minutes after the host's last heartbeat are refunded. Final. | Refunded | Most operations (input, screenshots, tasks, APK installs, snapshots) need the device to be `running` and return `409 conflict` otherwise, with a message such as `Device is creating, not running`. ### Waiting for a device `POST /v1/devices` returns as soon as the device is queued. Pass `"wait": true` (or `?wait=true`) to hold the request until the device leaves `queued`/`creating`, for at most 90 seconds. If it is still starting when the wait ends, you get the device in its current status with `201` and the response header `X-Wait-Timed-Out: 1`; poll `GET /v1/devices/:id` from there. A device that cannot be placed within **10 minutes** moves to `error` with `"error": "no_capacity"`. Queued time is never billed. ### Stopping `DELETE /v1/devices/:id` stops a device and returns it as `stopping` (or `terminated` if it was still queued). It is safe to call twice. AkaPhone also stops a device for you when: | Reason | When | |---|---| | `idle` | No activity for **30 minutes**. Activity is any input, task, task step, APK install, or an open live-view connection. | | `credits` | Your balance is below 1 credit when the next minute is due. | | `user` | You (or a teammate with your key) stopped it, or the account was suspended. | | `lost` | The host failed. | Viewers of the [live stream](https://akaphone-docs.pages.dev/concepts/streaming/) get a `device.stopped` event with the reason. ## Profiles and Android version | Field | Default | Notes | |---|---|---| | `profile` | `pixel_8` | Screen geometry; see the table below. Any other value is `400 invalid_request`. | | `android` | `"14"` | Android 14 is the image every host carries. | | `region` | `eu-central` | The only region today. | | `name` | none | Up to 64 characters, for your own reference. | | Profile | Name | Screen | dpi | |---|---|---|---| | `pixel_8` | Pixel 8 | 720×1560 | 320 | | `pixel_tablet` | Pixel Tablet | 1280×800 | 240 | | `small_phone` | Small phone | 540×960 | 240 | `GET /v1/profiles` returns the current list (no authentication needed), and the MCP tool `profiles_list` does the same for agents. Devices are Android Open Source Project builds without Google Play Services, so apps that need the Play Store or Play Integrity will not work. Install your own APKs instead. ## Concurrency Your plan limits how many devices you can have at once: **1** on Free, **3** on Dev, **5** on Team. Devices that are `queued`, `creating` or `running` count; `stopping` and finished devices do not. Creating one more returns `409 concurrency_limit`. See [Credits and plans](https://akaphone-docs.pages.dev/concepts/credits-and-plans/). The check is atomic, so parallel create requests cannot exceed the limit. ## Billing Minutes are prepaid and charged one at a time: minute 1 when the device becomes `running`, minute 2 sixty seconds later, and so on. A device that runs for 4 minutes 10 seconds costs 5 credits. Creating a device needs a balance of at least 1 credit (`402 insufficient_credits` otherwise). Each device's charged minutes are in `billed_minutes`. ## The device object ```json { "id": "dev_s6zr7mr4f2f1", "name": "checkout-test", "status": "running", "profile": "pixel_8", "android": "14", "region": "eu-central", "node_id": "nod_01m5vqsywg84jv64rmmqf9", "snapshot_id": null, "created_at": "2026-10-26T09:14:02.000Z", "ready_at": "2026-10-26T09:14:09.000Z", "terminated_at": null, "last_activity_at": "2026-10-26T09:16:40.000Z", "billed_minutes": 3, "error": null } ``` Device ids are exactly 16 characters (`dev_` and 12 lowercase base32 characters). `node_id` identifies the host; it matters only for [snapshots](https://akaphone-docs.pages.dev/concepts/snapshots/), which stay on the host that took them. Full endpoint reference: [Devices API](https://akaphone-docs.pages.dev/api/devices/). --- # Tasks > How plain-English tasks run on a device with Google's ARTEMIS agent and your own model key; flash and pro profiles, statuses, steps, artifacts and timeouts. Source: https://akaphone-docs.pages.dev/concepts/tasks/ A **task** is a plain-English instruction for a running device, such as *“Sign up with test@example.com, skip the tutorial and confirm the home screen shows Welcome”*. AkaPhone hands it to [ARTEMIS](https://github.com/google/artemis), Google's open-source Android agent, which runs on the device's host, looks at the screen, decides on an action (tap, type, swipe, key), performs it, and repeats until it is done or gives up. ARTEMIS runs unmodified. AkaPhone adds the device, the model key plumbing, progress events, artifacts and billing. ## Bring your own model key ARTEMIS needs a large language model. AkaPhone does not resell tokens: you send your own provider key with each task request. | Header | Value | |---|---| | `X-Model-Key` | Your provider API key. Kept in memory for the duration of the request and the task, then discarded. Never written to a database, log or audit record, and never echoed back. | | `X-Model-Provider` | `gemini` (the default when omitted), `anthropic` or `openai`. Anything else is `400 invalid_request`. | The host picks the model for the provider and profile. With the default host configuration that is: | Provider | `flash` | `pro` | |---|---|---| | `gemini` | `gemini-2.5-flash` | `gemini-2.5-pro` | | `anthropic` | `claude-sonnet-4-5` | `claude-sonnet-4-5` | | `openai` | `gpt-4.1` | `gpt-4.1` | Your provider bills you for the tokens. AkaPhone bills only the device minutes. More in [Model keys](https://akaphone-docs.pages.dev/guides/model-keys/). ## Profiles: `flash` and `pro` `profile` selects the ARTEMIS profile: - **`flash`** (the API default) is fast and cheap. Use it for short, direct actions: open an app, toggle a setting, read a value off the screen. - **`pro`** plans and verifies more. Use it for multi-step flows: sign-up, checkout, anything with branches or where you need a careful final check. > **The MCP server defaults to pro** > > `POST /v1/devices/:id/tasks` defaults to `flash`. The MCP tool `device_run_task` sends `pro` unless you or your agent ask for `flash`. ## One task per device A device runs one task at a time. Starting a second task while one is `queued` or `running` returns `409 conflict` with the id of the active task. Wait for it, or cancel it with `POST /v1/tasks/:id/cancel`. To run tasks in parallel, use more devices. ## Statuses ```text queued ──▶ running ──▶ succeeded │ ├──────▶ failed └──────────┴──────▶ cancelled ``` | Status | Meaning | |---|---| | `queued` | Accepted and sent to the host. | | `running` | ARTEMIS has started. `step_count` grows as it works. | | `succeeded` | ARTEMIS finished and reported success. `result` holds its final answer. | | `failed` | ARTEMIS gave up, the model call failed, the host could not run it, or it timed out. `error` says why. | | `cancelled` | You cancelled it. | If the device stops while a task is running, the host ends the task and it finishes as `failed` or `cancelled`. ### Timeouts A task that stays `queued` for more than **5 minutes**, or `running` for more than **1,900 seconds** (about 31 minutes), is failed with `"error": "timeout"`. The host itself stops ARTEMIS after 30 minutes. ## Steps Each action ARTEMIS takes is a **step** with a number, a short label and its duration in milliseconds. `GET /v1/tasks/:id` includes them: ```json "steps": [ { "n": 1, "label": "Open the Settings app", "ms": 2140 }, { "n": 2, "label": "Tap Display", "ms": 1830 }, { "n": 3, "label": "Turn on Dark theme", "ms": 1610 } ] ``` Watchers of the [live stream](https://akaphone-docs.pages.dev/api/stream/) get each step as a `task.step` event as it happens. ## Artifacts When a task ends, the host uploads its artifacts. `GET /v1/tasks/:id/artifacts` lists them with download URLs that are valid for 15 minutes: | File | `kind` | What it is | |---|---|---| | `video.mp4` | `video` | Screen recording of the whole task. | | `before.png`, `after.png` | `screenshot` | The screen before and after. | | `trace.tar.gz` | `trace` | The ARTEMIS trace directory. | | `stdout.log` | `log` | ARTEMIS output, with your model key redacted. | Artifacts are deleted after your plan's retention period: 7 days on Free, 30 on Dev, 90 on Team. Task records follow the same retention. ## Writing good instructions - **Say what done looks like.** *“…then confirm the cart shows 2 items”* gives the agent a check to run and gives you a pass or fail. - **Ask for the answer you want back.** *“Report the build number exactly as shown”* puts it in `result`. - **One flow per task.** Several small tasks on the same device are easier to debug than one long one, and each gets its own video. - **Use test accounts.** Do not automate accounts or services you do not own. ## The task object ```json { "id": "tsk_01m5vqsywgehe4jhva3g0r", "device_id": "dev_s6zr7mr4f2f1", "instruction": "Open Settings > About phone and report the Android version", "profile": "flash", "provider": "gemini", "status": "succeeded", "result": "Android version: 14", "error": null, "step_count": 4, "created_at": "2026-10-26T09:15:10.000Z", "started_at": "2026-10-26T09:15:11.000Z", "ended_at": "2026-10-26T09:15:29.000Z" } ``` `provider` is `null` when you did not send `X-Model-Provider`. `steps` (the array) is present only on `GET /v1/tasks/:id` and on the cancel response; every response has `step_count`. Full endpoint reference: [Tasks API](https://akaphone-docs.pages.dev/api/tasks/). --- # Snapshots > Save a device's disk (apps, logins, settings) and boot new devices from it. Snapshots are pinned to the host that took them, have per-plan quotas, and can be lost with their host. Source: https://akaphone-docs.pages.dev/concepts/snapshots/ A **snapshot** saves the disk of a running device: installed apps, their data and logins, system settings. You can then boot any number of new devices from it, each starting exactly where the snapshot left off. The original device is not affected. Typical uses: - Install your app and sign in to a test account once, then start every test run from that state. - Prepare a device with a specific language, region or accessibility setting (see [Localisation testing](https://akaphone-docs.pages.dev/guides/localisation-testing/)). - Keep a known-good state to compare against after a release. ## Take a snapshot The device must be `running`. ```bash title="curl" curl -X POST https://akaphone.abhgnx-417.workers.dev/v1/devices/dev_s6zr7mr4f2f1/snapshot \ -H "Authorization: Bearer $AKAPHONE_API_KEY" \ -H "Content-Type: application/json" \ -d '{"name": "signed-in-v2.3"}' ``` ```typescript title="TypeScript" const snap = await fetch("https://akaphone.abhgnx-417.workers.dev/v1/devices/dev_s6zr7mr4f2f1/snapshot", { method: "POST", headers: { Authorization: `Bearer ${process.env.AKAPHONE_API_KEY}`, "Content-Type": "application/json" }, body: JSON.stringify({ name: "signed-in-v2.3" }), }).then((r) => r.json()); ``` ```python title="Python" import os, requests snap = requests.post( "https://akaphone.abhgnx-417.workers.dev/v1/devices/dev_s6zr7mr4f2f1/snapshot", headers={"Authorization": f"Bearer {os.environ['AKAPHONE_API_KEY']}"}, json={"name": "signed-in-v2.3"}, ).json() ``` The response is `202` with the snapshot in `pending` status. It becomes `ready` when the host has finished writing it; poll `GET /v1/snapshots` to see the change. In the MCP server, the tool is `device_snapshot`. ## Restore Restoring creates a **new device** from the snapshot: ```bash curl -X POST https://akaphone.abhgnx-417.workers.dev/v1/snapshots/snp_01m5vqsywgez2ntdv5fq42/restore \ -H "Authorization: Bearer $AKAPHONE_API_KEY" \ -H "Content-Type: application/json" \ -d '{"name": "run-1842", "wait": true}' ``` This is the same as `POST /v1/devices` with `snapshot_id` set, and it accepts the same body (`name`, `profile`, `android`, `wait`). The snapshot must be `ready`. The new device counts toward your concurrency limit and bills like any other device. ## Pinned to one host In this version a snapshot is stored on the disk of the host that took it, and every device restored from it is placed on **that same host**. `node_id` on the snapshot tells you which host it is. This has three consequences: 1. **Restores can queue.** If that host has no free slot, the new device waits in `queued` even when other hosts are idle. After 10 minutes it gives up with `error: "no_capacity"`. 2. **Snapshots can be lost.** If the host fails permanently, its snapshots move to status `lost` and cannot be restored (`409 conflict: Snapshot was lost with the host that stored it`). Devices created from a lost snapshot are not affected once they are running. 3. **Snapshots can be refused.** A host that is low on disk refuses new snapshots with `409 conflict` (*Device host is low on disk; try again later*). Treat snapshots as a convenient cache, not a backup. Keep whatever you need to rebuild the state (APK, test account, setup task) so you can recreate a snapshot if it is lost. Moving snapshots to shared storage, so any host can restore them, is on the [roadmap](https://akaphone-docs.pages.dev/roadmap/). ## Statuses | Status | Meaning | |---|---| | `pending` | Being written by the host. Counts toward your quota. | | `ready` | Can be restored. Counts toward your quota. | | `error` | The host could not write it. Does not count. | | `lost` | Its host is gone. Cannot be restored. Does not count. | | `deleted` | You deleted it. Not listed. | ## Quotas | Plan | Snapshots you can hold (`pending` + `ready`) | |---|---| | Free | 1 | | Dev | 5 | | Team | 20 | Taking one more returns `409 conflict` (*Snapshot quota reached*); delete one first with `DELETE /v1/snapshots/:id`. Separately, you can take at most **5 snapshots per hour** (`429 rate_limited`). Snapshot storage is not billed. ## The snapshot object ```json { "id": "snp_01m5vqsywgez2ntdv5fq42", "device_id": "dev_s6zr7mr4f2f1", "node_id": "nod_01m5vqsywg84jv64rmmqf9", "name": "signed-in-v2.3", "size_bytes": 1873215488, "status": "ready", "created_at": "2026-10-26T09:20:44.000Z" } ``` `size_bytes` is `null` until the snapshot is `ready`. Full endpoint reference: [Snapshots API](https://akaphone-docs.pages.dev/api/snapshots/). --- # Live streaming > How the live screen works, frame rates per plan, controlling a device with taps, swipes, text and keys, and what a viewer keeps alive. Source: https://akaphone-docs.pages.dev/concepts/streaming/ Every running device can stream its screen to you while you watch. The dashboard uses this for its live view; you can use the same WebSocket from your own code (protocol in [Live stream API](https://akaphone-docs.pages.dev/api/stream/)). ## How it works 1. A viewer opens a WebSocket to `/v1/devices/:id/stream`. 2. When the first viewer connects, AkaPhone asks the device's host to start capturing. The host sends JPEG frames, AkaPhone relays each one to every connected viewer. 3. Taps, swipes, text and keys travel the other way and are executed on the device with `adb shell input`. 4. When the last viewer disconnects, capture stops. Streaming does not cost anything beyond the device minutes. Frames are only produced while someone is watching. To grab a single image without a stream, call `GET /v1/devices/:id/screenshot`. ## Frame rate | Plan | Frames per second | |---|---| | Free | 2 | | Dev | 4 | | Team | 5 | Frames are JPEG, downscaled by the host (540 px wide by default). When the screen does not change the host still resends a frame every 10 seconds, so a new viewer is never left blank for long. A viewer that connects gets the last frame immediately if one exists. ## Input The same four input types work over the stream socket and over `POST /v1/devices/:id/input`: | Type | Fields | Example | |---|---|---| | `tap` | `x`, `y` (device pixels, 0 to 10000) | `{"type":"tap","x":360,"y":780}` | | `swipe` | `x1`, `y1`, `x2`, `y2`, `ms` (duration, 1 to 10000, default 300) | `{"type":"swipe","x1":360,"y1":1200,"x2":360,"y2":400,"ms":250}` | | `text` | `text` (1 to 500 characters) | `{"type":"text","text":"hello@example.com"}` | | `key` | `key`: `HOME`, `BACK`, `RECENTS`, `ENTER`, `DEL` or `TAB` | `{"type":"key","key":"BACK"}` | Coordinates are in the device's own pixels (for the default `pixel_8` profile, 720×1560), not in the downscaled frame. Scale clicks on a frame by `device width / frame width`. Input on the socket is limited to **20 messages per second per connection**. Extra messages are dropped with an `error` event. Long text is best sent as one `text` message rather than one per character. ## Viewers keep a device alive A device with at least one open stream connection is never stopped as idle. Close the socket (or the browser tab) when you are done watching, or stop the device. If your balance runs out, the device stops anyway and viewers get `device.stopped` with reason `credits`. ## Authentication Browsers cannot set an `Authorization` header on a WebSocket, so there are three ways in: | Who | How | |---|---| | The dashboard | The `ak_session` cookie, from `app.akaphone.com` only. | | Servers and scripts | `Authorization: Bearer ak_live_…` on the upgrade request. | | Your own web page | Get a short-lived stream token from your backend with `POST /v1/devices/:id/stream-token`, then connect with `?key=`. Tokens work once, for one device, for 5 minutes. | Never put a `ak_live_` key in front-end code. Stream tokens exist so you do not have to. --- # Credits and plans > Prepaid credits (1 credit = 1 device-minute), the 60-minute signup grant, credit packs, and the Free, Dev and Team plan limits. Source: https://akaphone-docs.pages.dev/concepts/credits-and-plans/ AkaPhone is prepaid. You buy **credits**, and running devices spend them. There is no subscription and nothing is charged to a card automatically. **1 credit = 1 device-minute.** A device that is `running` costs 1 credit per started minute, whatever it is doing. Queued and booting time is free, and so are tasks, streaming, APK installs, snapshots and API calls. Model tokens are billed by your model provider on your own key. ## Free minutes Every new account gets **60 credits** once, at first sign-in. That is an hour of one device. No card is needed. ## Credit packs | Pack | Price | Credits | Per minute | |---|---|---|---| | `250` | $10 | 250 | $0.040 | | `1375` | $50 | 1,375 (+10%) | $0.036 | | `6000` | $200 | 6,000 (+20%) | $0.033 | | `team` | $199 | 6,000, plus the Team plan for 30 days | $0.033 | Checkout is hosted by Lemon Squeezy, our merchant of record, which handles sales tax and VAT. Credits do not expire. See [Billing](https://akaphone-docs.pages.dev/guides/billing/) for buying, receipts and refunds. ## Plans Your plan sets limits, not prices. It changes automatically with what you buy: | | Free | Dev | Team | |---|---|---|---| | How you get it | Sign up | Buy any pack | Buy the `team` pack | | Devices at once | 1 | 3 | 5 | | Live view frame rate | 2 fps | 4 fps | 5 fps | | Snapshots held | 1 | 5 | 20 | | Artifact and task retention | 7 days | 30 days | 90 days | - Your first purchase of any pack moves you from **Free** to **Dev** permanently. - The **Team** pack sets the Team plan for 30 days. Buying it again while Team is active extends it by 30 days from the current end date. When it ends you are back on Dev, and any credits you have left stay yours. - **Enterprise** plans with custom limits are arranged by contract. `GET /v1/me` returns your plan, when it expires (`plan_expires_at`, Team only), your concurrency limit and your balance. ## How the meter works - Minute 1 is charged the moment the device becomes `running`; minute *n* is charged at *ready + (n − 1) minutes*. - Before charging a minute AkaPhone checks your balance is at least 1. If it is not, the device stops with reason `credits`. - Viewers of the live stream get a `credits.low` event while your balance is 5 or less. - Creating a device needs a balance of at least 1 (`402 insufficient_credits`). - If the host running a device fails, the device becomes `lost` and minutes prepaid after the host's last heartbeat are refunded. Usage is written to your ledger in 10-minute windows per device, so the ledger has one `usage` row per device per window rather than one per minute. `GET /v1/usage` and `used_this_month` in `GET /v1/me` include minutes not yet written, so they are always exact. ## Worked examples - A 5-minute test run on one device: 5 credits, $0.20 at the $10 pack rate. - Three devices for 20 minutes each in CI: 60 credits. - A device left running and forgotten: stopped after 30 idle minutes, so at most about 31 credits. --- # Install an APK > Install your own app on a device by dragging it into the dashboard, or over the API with a presigned upload, a completion check and an install request. Source: https://akaphone-docs.pages.dev/guides/install-apk/ You can install any APK up to **300 MB** on a running device. Files go straight from you to storage through a presigned URL, never through the API server, and the device downloads them from there. ## From Claude Code, Cursor or Codex The MCP tool `device_install_apk` does the whole flow from a local file path and waits up to 5 minutes for the result: > Install ./app/build/outputs/apk/debug/app-debug.apk on checkout-test, then open the app and take a screenshot. ## In the dashboard Open a running device and drag the `.apk` file onto the page. You see upload progress, then *Verifying upload*, then the install. When the device reports back you get a toast with the package name, for example *Installed com.example.shop*, or the installer's error. ## Over the API The flow has five calls: 1. `POST /v1/uploads` declares the file and returns a presigned `put_url`. 2. `PUT` the file bytes to `put_url`. 3. `POST /v1/uploads/:id/complete` confirms the file arrived. 4. `POST /v1/devices/:id/apps` installs it on a running device. 5. `GET /v1/devices/:id/apps/:install_id` tells you when it is installed. An upload can be installed on as many devices as you like for **7 days**, after which it is deleted. ### 1. Create the upload ```bash FILE=app-release.apk SIZE=$(wc -c < "$FILE" | tr -d ' ') curl -s -X POST https://akaphone.abhgnx-417.workers.dev/v1/uploads \ -H "Authorization: Bearer $AKAPHONE_API_KEY" \ -H "Content-Type: application/json" \ -d "{\"filename\": \"$FILE\", \"size_bytes\": $SIZE, \"content_type\": \"application/vnd.android.package-archive\"}" ``` ```json { "upload": { "id": "upl_01m5vqsywgw57haqkjnexz", "filename": "app-release.apk", "size_bytes": 48211337, "status": "pending" }, "put_url": "https://.r2.cloudflarestorage.com/akaphone-artifacts/apks/usr_01m5vqsywg3mxj7dyxv9q9/upl_01m5vqsywgw57haqkjnexz/app-release.apk?X-Amz-Algorithm=AWS4-HMAC-SHA256&...", "headers": { "content-type": "application/vnd.android.package-archive" }, "expires_in": 900 } ``` Rules for the request: - `filename`: 5 to 128 characters, letters, digits, `.`, `_`, `-` and spaces, starting with a letter or digit and ending in `.apk`. Spaces become `_`. `.aab` bundles are rejected with `400`. - `size_bytes`: the exact size of the file in bytes, at most 314,572,800 (300 MB). - `content_type`: optional, defaults to `application/vnd.android.package-archive`. ### 2. Upload the bytes `PUT` the file to `put_url` within `expires_in` seconds (15 minutes). Send every header from `headers`, and send **exactly** `size_bytes` bytes: the URL is signed for that content type and that length, and storage rejects anything else. ```bash curl -s -X PUT "$PUT_URL" \ -H "Content-Type: application/vnd.android.package-archive" \ --data-binary @"$FILE" ``` Do not send your `Authorization` header to `put_url`; the signature in the URL is the authorization. ### 3. Complete ```bash curl -s -X POST https://akaphone.abhgnx-417.workers.dev/v1/uploads/$UPLOAD_ID/complete \ -H "Authorization: Bearer $AKAPHONE_API_KEY" ``` ```json { "upload": { "id": "upl_01m5vqsywgw57haqkjnexz", "status": "uploaded" } } ``` AkaPhone checks that the object exists in storage. If you call this before the `PUT` has finished you get `409 conflict` (*Object not found in storage*). Calling it again after success is harmless. ### 4. Install ```bash curl -s -X POST https://akaphone.abhgnx-417.workers.dev/v1/devices/$DEVICE_ID/apps \ -H "Authorization: Bearer $AKAPHONE_API_KEY" \ -H "Content-Type: application/json" \ -d "{\"upload_id\": \"$UPLOAD_ID\"}" ``` ```json { "install_id": "ins_01m5vqsywg99189k5xj7f5" } ``` The response is `202`: the device's host downloads the file and runs `adb install -r -g`, which replaces an existing install of the same package and grants all runtime permissions up front. ### 5. Wait for the result Poll `GET /v1/devices/:id/apps/:install_id` until `status` is `installed` or `failed`: ```bash while :; do INSTALL=$(curl -s https://akaphone.abhgnx-417.workers.dev/v1/devices/$DEVICE_ID/apps/$INSTALL_ID -H "Authorization: Bearer $AKAPHONE_API_KEY") case $(echo "$INSTALL" | jq -r .status) in installed|failed) break;; esac sleep 3 done echo "$INSTALL" ``` ```json { "install_id": "ins_01m5vqsywg99189k5xj7f5", "status": "installed", "package": "com.example.shop", "created_at": "2026-10-26T09:20:00.000Z", "finished_at": "2026-10-26T09:20:06.000Z" } ``` On failure `error` holds the installer's message. An install still pending after 15 minutes fails with `timeout`. The same result is also pushed as an `app.installed` event to anyone watching the [live stream](https://akaphone-docs.pages.dev/api/stream/#events). ## Everything in one script ```bash title="curl" #!/usr/bin/env bash set -euo pipefail API=https://akaphone.abhgnx-417.workers.dev AUTH="Authorization: Bearer $AKAPHONE_API_KEY" FILE=${1:-app-release.apk} DEVICE_ID=$2 SIZE=$(wc -c < "$FILE" | tr -d ' ') UP=$(curl -sf -X POST "$API/v1/uploads" -H "$AUTH" -H "Content-Type: application/json" \ -d "{\"filename\": \"$(basename "$FILE")\", \"size_bytes\": $SIZE}") UPLOAD_ID=$(echo "$UP" | jq -r .upload.id) PUT_URL=$(echo "$UP" | jq -r .put_url) CTYPE=$(echo "$UP" | jq -r '.headers["content-type"]') curl -sf -X PUT "$PUT_URL" -H "Content-Type: $CTYPE" --data-binary @"$FILE" curl -sf -X POST "$API/v1/uploads/$UPLOAD_ID/complete" -H "$AUTH" > /dev/null INSTALL_ID=$(curl -sf -X POST "$API/v1/devices/$DEVICE_ID/apps" -H "$AUTH" -H "Content-Type: application/json" \ -d "{\"upload_id\": \"$UPLOAD_ID\"}" | jq -r .install_id) for i in $(seq 1 100); do STATUS=$(curl -sf "$API/v1/devices/$DEVICE_ID/apps/$INSTALL_ID" -H "$AUTH" | jq -r .status) [ "$STATUS" != pending ] && break sleep 3 done echo "install: $STATUS" [ "$STATUS" = installed ] ``` ```typescript title="TypeScript" import { readFile } from "node:fs/promises"; import { basename } from "node:path"; const API = "https://akaphone.abhgnx-417.workers.dev"; const auth = { Authorization: `Bearer ${process.env.AKAPHONE_API_KEY}` }; async function installApk(path: string, deviceId: string) { const bytes = await readFile(path); const up = await fetch(`${API}/v1/uploads`, { method: "POST", headers: { ...auth, "Content-Type": "application/json" }, body: JSON.stringify({ filename: basename(path), size_bytes: bytes.length }), }).then((r) => r.json()); const put = await fetch(up.put_url, { method: "PUT", headers: up.headers, body: bytes }); if (!put.ok) throw new Error(`upload failed: ${put.status}`); await fetch(`${API}/v1/uploads/${up.upload.id}/complete`, { method: "POST", headers: auth }); const res = await fetch(`${API}/v1/devices/${deviceId}/apps`, { method: "POST", headers: { ...auth, "Content-Type": "application/json" }, body: JSON.stringify({ upload_id: up.upload.id }), }); const { install_id } = await res.json(); for (let i = 0; i < 100; i++) { const inst = await fetch(`${API}/v1/devices/${deviceId}/apps/${install_id}`, { headers: auth }).then((r) => r.json()); if (inst.status !== "pending") return inst; // { status: "installed" | "failed", package?, error? } await new Promise((r) => setTimeout(r, 3000)); } throw new Error("install still pending"); } ``` ```python title="Python" import os, time, requests API = "https://akaphone.abhgnx-417.workers.dev" AUTH = {"Authorization": f"Bearer {os.environ['AKAPHONE_API_KEY']}"} def install_apk(path: str, device_id: str) -> dict: size = os.path.getsize(path) up = requests.post(f"{API}/v1/uploads", headers=AUTH, json={"filename": os.path.basename(path), "size_bytes": size}).json() with open(path, "rb") as f: requests.put(up["put_url"], headers=up["headers"], data=f).raise_for_status() requests.post(f"{API}/v1/uploads/{up['upload']['id']}/complete", headers=AUTH).raise_for_status() r = requests.post(f"{API}/v1/devices/{device_id}/apps", headers=AUTH, json={"upload_id": up["upload"]["id"]}) r.raise_for_status() install_id = r.json()["install_id"] for _ in range(100): inst = requests.get(f"{API}/v1/devices/{device_id}/apps/{install_id}", headers=AUTH).json() if inst["status"] != "pending": return inst # {"status": "installed" | "failed", "package"?, "error"?} time.sleep(3) raise TimeoutError("install still pending") ``` ## Notes and limits - **APKs only.** Devices install with `adb install`, which cannot install an Android App Bundle, so uploads of `.aab` files are rejected. Build a universal APK from your bundle (for example with `bundletool build-apks --mode=universal`) and upload that. - **No Google Play Services.** Devices are AOSP builds. Apps that require Play Services or Play Integrity may install but fail at runtime. - **ABI.** Devices run on x86_64 hosts. Apps that ship only ARM native libraries may fail to install with `INSTALL_FAILED_NO_MATCHING_ABIS`; include an x86_64 build or a universal APK. - **Errors.** `404 not_found` for an unknown upload, `409 conflict` if the upload is not complete or the device is not running, `400 invalid_request` for a bad file name or size. Endpoint reference: [Uploads API](https://akaphone-docs.pages.dev/api/uploads/) and [Devices API › Install an app](https://akaphone-docs.pages.dev/api/devices/#install-an-app). --- # Testing in CI > Run a plain-English test on a fresh cloud phone from GitHub Actions using curl and the REST API, fail the build on a failed task, and keep the video. Source: https://akaphone-docs.pages.dev/guides/testing-in-ci/ This guide runs an end-to-end check on every pull request: boot a device, install the APK your workflow just built, ask ARTEMIS to walk through a flow, fail the job if the task fails, upload the video and screenshots as build artifacts, and always stop the device. It uses only `curl` and `jq`, which are preinstalled on GitHub-hosted Ubuntu runners. ## Secrets Add two repository secrets (**Settings › Secrets and variables › Actions**): | Secret | Value | |---|---| | `AKAPHONE_API_KEY` | A `ak_live_…` key. Create a dedicated one named `github-actions` so you can revoke it alone. | | `MODEL_API_KEY` | Your Gemini key (or Anthropic/OpenAI; then set `MODEL_PROVIDER` below). | ## The workflow ```yaml title=".github/workflows/phone-test.yml" name: Phone test on: [pull_request] jobs: phone-test: runs-on: ubuntu-latest timeout-minutes: 30 env: API: https://akaphone.abhgnx-417.workers.dev AUTH: "Authorization: Bearer ${{ secrets.AKAPHONE_API_KEY }}" MODEL_API_KEY: ${{ secrets.MODEL_API_KEY }} MODEL_PROVIDER: gemini steps: - uses: actions/checkout@v4 # Build your app here; this example expects app/build/outputs/apk/debug/app-debug.apk - name: Build APK run: ./gradlew assembleDebug - name: Start a device run: | DEV=$(curl -sf -X POST "$API/v1/devices" -H "$AUTH" -H "Content-Type: application/json" \ -d "{\"name\": \"ci-${GITHUB_RUN_ID}\", \"wait\": true}") DEVICE_ID=$(echo "$DEV" | jq -r .id) echo "DEVICE_ID=$DEVICE_ID" >> "$GITHUB_ENV" # wait=true holds up to 90 s; keep polling if the device is still starting. for i in $(seq 1 60); do STATUS=$(curl -sf "$API/v1/devices/$DEVICE_ID" -H "$AUTH" | jq -r .status) [ "$STATUS" = running ] && break case "$STATUS" in error|lost|terminated) echo "device $STATUS"; exit 1;; esac sleep 5 done [ "$STATUS" = running ] || { echo "device did not start"; exit 1; } - name: Install the APK run: | FILE=app/build/outputs/apk/debug/app-debug.apk SIZE=$(wc -c < "$FILE" | tr -d ' ') UP=$(curl -sf -X POST "$API/v1/uploads" -H "$AUTH" -H "Content-Type: application/json" \ -d "{\"filename\": \"app-debug.apk\", \"size_bytes\": $SIZE}") UPLOAD_ID=$(echo "$UP" | jq -r .upload.id) curl -sf -X PUT "$(echo "$UP" | jq -r .put_url)" \ -H "Content-Type: $(echo "$UP" | jq -r '.headers["content-type"]')" \ --data-binary @"$FILE" curl -sf -X POST "$API/v1/uploads/$UPLOAD_ID/complete" -H "$AUTH" > /dev/null INSTALL_ID=$(curl -sf -X POST "$API/v1/devices/$DEVICE_ID/apps" -H "$AUTH" -H "Content-Type: application/json" \ -d "{\"upload_id\": \"$UPLOAD_ID\"}" | jq -r .install_id) for i in $(seq 1 100); do INSTALL=$(curl -sf "$API/v1/devices/$DEVICE_ID/apps/$INSTALL_ID" -H "$AUTH") STATUS=$(echo "$INSTALL" | jq -r .status) [ "$STATUS" != pending ] && break sleep 3 done echo "$INSTALL" [ "$STATUS" = installed ] || { echo "install $STATUS"; exit 1; } - name: Run the test run: | INSTRUCTION='Open the Example Shop app. Add the first product to the cart, open the cart and check it shows 1 item. Report PASS if it does, otherwise FAIL and what you saw.' TASK=$(curl -sf -X POST "$API/v1/devices/$DEVICE_ID/tasks" -H "$AUTH" \ -H "Content-Type: application/json" \ -H "X-Model-Key: $MODEL_API_KEY" -H "X-Model-Provider: $MODEL_PROVIDER" \ -d "$(jq -n --arg i "$INSTRUCTION" '{instruction: $i, profile: "pro"}')") TASK_ID=$(echo "$TASK" | jq -r .id) echo "TASK_ID=$TASK_ID" >> "$GITHUB_ENV" while :; do TASK=$(curl -sf "$API/v1/tasks/$TASK_ID" -H "$AUTH") STATUS=$(echo "$TASK" | jq -r .status) case "$STATUS" in succeeded|failed|cancelled) break;; esac sleep 5 done echo "$TASK" | jq '{status, result, error, steps: [.steps[].label]}' [ "$STATUS" = succeeded ] || exit 1 echo "$TASK" | jq -r .result | grep -q PASS - name: Download artifacts if: always() && env.TASK_ID != '' run: | mkdir -p phone-artifacts sleep 5 # artifacts are uploaded right after the task ends curl -sf "$API/v1/tasks/$TASK_ID/artifacts" -H "$AUTH" \ | jq -r '.files[] | "\(.name) \(.url)"' \ | while read -r NAME URL; do curl -sf -o "phone-artifacts/$NAME" "$URL"; done - uses: actions/upload-artifact@v4 if: always() with: name: phone-test path: phone-artifacts/ if-no-files-found: ignore - name: Stop the device if: always() && env.DEVICE_ID != '' run: curl -sf -X DELETE "$API/v1/devices/$DEVICE_ID" -H "$AUTH" > /dev/null ``` ## How it decides pass or fail `status` tells you whether ARTEMIS finished: `succeeded` means it completed the instruction, `failed` means it could not (or the model call failed, or it timed out). That alone does not mean your app behaved correctly, so the instruction asks the agent to **report PASS or FAIL** and the step greps `result` for it. Write instructions with an explicit check and an explicit verdict. ## Cost Each run uses one device for the build-independent part of the job: typically 3 to 8 minutes, so 3 to 8 credits. The `if: always()` stop step matters: without it, a cancelled or failed job leaves the device running until the 30-minute idle stop. ## Faster runs with a snapshot If every run needs the same setup (a signed-in test account, onboarding skipped), do it once on a device, [take a snapshot](https://akaphone-docs.pages.dev/concepts/snapshots/), and start CI devices from it: ```bash curl -sf -X POST "$API/v1/snapshots/$SNAPSHOT_ID/restore" -H "$AUTH" \ -H "Content-Type: application/json" -d '{"name": "ci", "wait": true}' ``` Remember that snapshots are pinned to one host: if it is busy the device queues, and if the host is lost so is the snapshot. Keep the setup scripted so you can recreate it. ## Parallel runs Start one device per flow and run the steps in a matrix. Your plan's concurrency limit applies across everything on your account (3 devices on Dev, 5 on Team), and device creation is limited to 10 per minute. A `409 concurrency_limit` in CI means other jobs or people are using your devices. --- # Localisation testing > Check your app in several languages by preparing one snapshot per locale and running the same plain-English task on each, with screenshots and video to compare. Source: https://akaphone-docs.pages.dev/guides/localisation-testing/ ARTEMIS reads the screen the way a person does, so the same instruction works whatever language the UI is in. That makes it a good fit for localisation checks: truncated buttons, untranslated strings, layouts that break with long German words or right-to-left Arabic. There is no locale parameter when creating a device; every device boots in English (United States). You set the language once per locale on a device and save it as a [snapshot](https://akaphone-docs.pages.dev/concepts/snapshots/). ## 1. Prepare one snapshot per locale For each locale, start a device, set the language with a task, install your app, and snapshot it. ```bash API=https://akaphone.abhgnx-417.workers.dev AUTH="Authorization: Bearer $AKAPHONE_API_KEY" DEVICE_ID=$(curl -sf -X POST "$API/v1/devices" -H "$AUTH" -H "Content-Type: application/json" \ -d '{"name": "prep-de-DE", "wait": true}' | jq -r .id) curl -sf -X POST "$API/v1/devices/$DEVICE_ID/tasks" -H "$AUTH" -H "Content-Type: application/json" \ -H "X-Model-Key: $MODEL_API_KEY" \ -d '{"profile": "pro", "instruction": "Open Settings > System > Languages. Add Deutsch (Deutschland) and move it to the top of the list so it becomes the system language. Confirm the Settings app is now in German."}' ``` When the task has succeeded, [install your APK](https://akaphone-docs.pages.dev/guides/install-apk/), then: ```bash curl -sf -X POST "$API/v1/devices/$DEVICE_ID/snapshot" -H "$AUTH" -H "Content-Type: application/json" \ -d '{"name": "locale-de-DE"}' ``` Wait until the snapshot is `ready` in `GET /v1/snapshots`, stop the device, and repeat for each locale. On the Free plan you can hold one snapshot; Dev allows 5 and Team 20. > **Do it in the dashboard** > > The same preparation works by hand: open a device, change the language in Settings using the live screen, drag in your APK, and click **Snapshot**. ## 2. Run the same check in every locale Restore one device per locale and give each the same instruction. Write it in English and describe elements by purpose rather than by label: > Open the Example Shop app. Go through onboarding, open the product list and the cart. On each screen, report any text that is cut off, overlaps other elements, or is still in English. Reply PASS if there are none, otherwise FAIL and a list of problems with the screen they are on. ```bash for SNAP in "$SNAP_DE" "$SNAP_JA" "$SNAP_AR"; do DEV=$(curl -sf -X POST "$API/v1/snapshots/$SNAP/restore" -H "$AUTH" \ -H "Content-Type: application/json" -d '{"wait": true}' | jq -r .id) curl -sf -X POST "$API/v1/devices/$DEV/tasks" -H "$AUTH" -H "Content-Type: application/json" \ -H "X-Model-Key: $MODEL_API_KEY" \ -d "$(jq -n --arg i "$INSTRUCTION" '{instruction: $i, profile: "pro"}')" | jq -r .id done ``` This starts the devices in parallel, up to your plan's concurrency limit (3 on Dev, 5 on Team). Poll each task with `GET /v1/tasks/:id`, then collect its artifacts with `GET /v1/tasks/:id/artifacts`: the video and the before/after screenshots make a side-by-side review quick. Stop each device when its task is done. ## Tips - **Use `pro`** for locale checks. Judging truncation and stray English needs the more careful profile. - **Check right-to-left separately.** Ask explicitly whether the layout is mirrored for Arabic or Hebrew. - **Keep instructions in English.** ARTEMIS works fine with an English instruction on a German screen, and your results stay comparable across locales. - **Rebuild snapshots per release** of your app, or install the new APK on a restored device before the check. - **Remember snapshots live on one host.** If a snapshot is `lost`, rerun step 1 for that locale. --- # Model keys > Bring your own Gemini, Anthropic or OpenAI key for tasks. How to send it, where it lives, which models are used and how to keep it safe. Source: https://akaphone-docs.pages.dev/guides/model-keys/ ARTEMIS, the agent that carries out [tasks](https://akaphone-docs.pages.dev/concepts/tasks/), calls a large language model on every step. AkaPhone does not resell model access. You bring your own key, your provider bills you for tokens, and AkaPhone bills only device minutes. ## Supported providers | `X-Model-Provider` | Key from | Looks like | |---|---|---| | `gemini` (default) | [Google AI Studio](https://aistudio.google.com/apikey) | `AIza…` | | `anthropic` | [Anthropic Console](https://console.anthropic.com/) | `sk-ant-…` | | `openai` | [OpenAI Platform](https://platform.openai.com/api-keys) | `sk-…` | Any other provider value is rejected with `400 invalid_request`. ## Sending the key Send it with each task request, in headers: ```bash title="curl" curl -X POST https://akaphone.abhgnx-417.workers.dev/v1/devices/dev_s6zr7mr4f2f1/tasks \ -H "Authorization: Bearer $AKAPHONE_API_KEY" \ -H "X-Model-Key: $ANTHROPIC_API_KEY" \ -H "X-Model-Provider: anthropic" \ -H "Content-Type: application/json" \ -d '{"instruction": "Open the Clock app and set an alarm for 7:30", "profile": "flash"}' ``` ```typescript title="TypeScript" await fetch("https://akaphone.abhgnx-417.workers.dev/v1/devices/dev_s6zr7mr4f2f1/tasks", { method: "POST", headers: { Authorization: `Bearer ${process.env.AKAPHONE_API_KEY}`, "X-Model-Key": process.env.ANTHROPIC_API_KEY!, "X-Model-Provider": "anthropic", "Content-Type": "application/json", }, body: JSON.stringify({ instruction: "Open the Clock app and set an alarm for 7:30", profile: "flash" }), }); ``` ```python title="Python" import os, requests requests.post( "https://akaphone.abhgnx-417.workers.dev/v1/devices/dev_s6zr7mr4f2f1/tasks", headers={ "Authorization": f"Bearer {os.environ['AKAPHONE_API_KEY']}", "X-Model-Key": os.environ["ANTHROPIC_API_KEY"], "X-Model-Provider": "anthropic", }, json={"instruction": "Open the Clock app and set an alarm for 7:30", "profile": "flash"}, ) ``` - **MCP server:** set `MODEL_API_KEY` (and `MODEL_PROVIDER` for Anthropic or OpenAI) in the server's environment. It adds the headers to every `device_run_task` call. - **Dashboard:** paste the key in **Run a task**. It is saved in your browser's local storage on that computer only, so you do not have to paste it each time, and sent with each task. **Forget key** deletes it from the browser. If you send no key at all, the task is still accepted, but ARTEMIS will have no model to call unless the host has an operator default configured, and on AkaPhone's hosted service you should assume it does not. The task then fails. ## Where the key goes 1. Your request arrives at the API over HTTPS. 2. The API passes the key, in memory, in the `task.run` message to the device's host. It is not written to the database, to any log, or to the audit trail, and it is never returned in a response. The API's own tests check the database, audit log and KV storage for it. 3. On the host, the key exists only in the memory of the agent and in the environment of the ARTEMIS process for that one task. It is never on the command line or on disk. Every log line, the task log artifact and the task `result` pass through a redactor that removes it. 4. When the task ends the process exits and the key is gone. So you have to send the key with every task. That is deliberate: there is nothing stored to leak, rotate or delete. ## Models The host chooses the model from the provider and the task profile: | Provider | `flash` | `pro` | |---|---|---| | `gemini` | `gemini-2.5-flash` | `gemini-2.5-pro` | | `anthropic` | `claude-sonnet-4-5` | `claude-sonnet-4-5` | | `openai` | `gpt-4.1` | `gpt-4.1` | You cannot pick a specific model per task today. ## Keeping it safe - **Use a dedicated key** for AkaPhone with a spending limit set at your provider. - **The device can see what you type into it, not your model key.** The key never reaches the Android system; it stays with ARTEMIS on the host. - **Rotate at your provider** whenever you like. There is nothing to update on AkaPhone's side. - See [Security](https://akaphone-docs.pages.dev/security/) for how devices on a shared host are isolated, and what that means for credentials you type into a device. --- # Billing > Buy credit packs through Lemon Squeezy checkout, read the credit ledger, understand plan changes, and how refunds are applied. Source: https://akaphone-docs.pages.dev/guides/billing/ AkaPhone sells prepaid credit packs. One credit is one device-minute. See [Credits and plans](https://akaphone-docs.pages.dev/concepts/credits-and-plans/) for what each plan includes. ## Packs | Pack id | Price | Credits | Also | |---|---|---|---| | `250` | $10 | 250 | Moves Free accounts to Dev | | `1375` | $50 | 1,375 | Moves Free accounts to Dev | | `6000` | $200 | 6,000 | Moves Free accounts to Dev | | `team` | $199 | 6,000 | Team plan for 30 days | Prices are in US dollars. Sales tax or VAT is added at checkout where it applies. ## Buying credits ### In the dashboard Open **Billing**, choose a pack and click **Buy**. You are taken to a Lemon Squeezy checkout page. After paying you return to `https://akaphone.abhgnx-417.workers.dev/billing?checkout=success`, and the credits appear as soon as Lemon Squeezy confirms the order, normally within a few seconds. ### Over the API `POST /v1/billing/checkout` returns a checkout URL for the pack. Open it in a browser to pay: ```bash title="curl" curl -X POST https://akaphone.abhgnx-417.workers.dev/v1/billing/checkout \ -H "Authorization: Bearer $AKAPHONE_API_KEY" \ -H "Content-Type: application/json" \ -d '{"pack": "1375"}' ``` ```typescript title="TypeScript" const { url } = await fetch("https://akaphone.abhgnx-417.workers.dev/v1/billing/checkout", { method: "POST", headers: { Authorization: `Bearer ${process.env.AKAPHONE_API_KEY}`, "Content-Type": "application/json" }, body: JSON.stringify({ pack: "1375" }), }).then((r) => r.json()); ``` ```python title="Python" import os, requests url = requests.post( "https://akaphone.abhgnx-417.workers.dev/v1/billing/checkout", headers={"Authorization": f"Bearer {os.environ['AKAPHONE_API_KEY']}"}, json={"pack": "1375"}, ).json()["url"] ``` ```json { "url": "https://akaphone.lemonsqueezy.com/checkout/custom/4f1c0e7a-…" } ``` The checkout is tied to your account, so the credits go to the account that requested it, whoever pays. ## Receipts and invoices Lemon Squeezy is the merchant of record. It emails the receipt to the address used at checkout and handles tax. For an invoice with your company details, use the link in that receipt email. ## The ledger Every change to your balance is a row in the credit ledger: **Billing › Credit ledger** in the dashboard, or `GET /v1/billing/ledger`. | `kind` | `delta` | `ref` | When | |---|---|---|---| | `grant` | +60 | `signup:usr_…` | Your first sign-in. | | `purchase` | + pack credits | Lemon Squeezy order id | A paid order. | | `usage` | − minutes | `dev_…:w` | Device minutes, one row per device per 10-minute window. | | `usage` | − minutes | `usr_…:YYYY-MM` | After 90 days, usage rows are combined into one row per month. | | `refund` | − credits | `:r` | A refund (see below). | | `adjust` | ± | varies | A manual correction by support. | ```json { "entries": [ { "id": "led_01m5vqsywgmvjvevzqnbkz", "delta": -10, "kind": "usage", "ref": "dev_s6zr7mr4f2f1:w1", "created_at": "2026-10-26T09:24:09.000Z" }, { "id": "led_01m5vqsywgmzjsatq42611", "delta": 1375, "kind": "purchase", "ref": "4821733", "created_at": "2026-10-26T09:02:51.000Z" }, { "id": "led_01m5vqsywgadfgngs4y1q2", "delta": 60, "kind": "grant", "ref": "signup:usr_01m5vqsywg3mxj7dyxv9q9", "created_at": "2026-10-20T15:40:12.000Z" } ] } ``` Your balance is the sum of the ledger minus minutes charged in the current, not yet written, 10-minute windows. `GET /v1/me` always returns the exact current balance. ## Refunds Refunds are issued through Lemon Squeezy. Email [hello@akaphone.com](mailto:hello@akaphone.com) with the order number from your receipt. When a refund is processed: - **Credits are taken back in proportion** to the amount refunded: refunding half of a 1,375-credit pack removes 688 credits (rounded). Further partial refunds of the same order take back only the difference. - **Your balance can go negative** if you already used those credits. While it is negative (strictly, below 1) you cannot create devices, and running devices stop at their next minute. - **Refunding a Team pack**, fully or partly, ends the Team plan immediately; you return to Dev. ## Running out - Creating a device needs a balance of at least 1 credit, otherwise `402 insufficient_credits`. - While a device runs, the dashboard and stream viewers get a `credits.low` warning when the balance is 5 or less. - When a minute is due and the balance is below 1, the device stops with reason `credits`. Your data on that device is gone, as with any stop, so snapshot anything you want to keep while you still have credits. --- # Limits and quotas > Every limit in one place - rate limits, concurrent devices, snapshot quotas, input and instruction lengths, upload sizes, timeouts and data retention. Source: https://akaphone-docs.pages.dev/guides/limits-and-quotas/ ## Rate limits Limits are per account (all your API keys and your dashboard session share them) and work as token buckets that refill continuously. | What | Limit | Error | |---|---|---| | All authenticated REST requests (`/v1/…`) | 120 per minute | `429 rate_limited` | | Device creation (`POST /v1/devices`, snapshot restores) | 10 per minute | `429 rate_limited` | | Task creation (`POST /v1/devices/:id/tasks`) | 30 per minute | `429 rate_limited` | | Snapshot creation | 5 per hour | `429 rate_limited` | | Input over the live stream | 20 messages per second per connection | `error` event on the socket | | Starting Google sign-in | 30 per minute per IP address | `429 rate_limited` | A `429` response carries a `Retry-After` header with the number of seconds to wait. There are no `X-RateLimit-*` headers. Back off for `Retry-After` seconds and retry; the [REST overview](https://akaphone-docs.pages.dev/api/rest/#rate-limits) has a retry helper. ## Account limits | | Free | Dev | Team | |---|---|---|---| | Devices at once (`queued` + `creating` + `running`) | 1 | 3 | 5 | | Snapshots held (`pending` + `ready`) | 1 | 5 | 20 | | Live view frame rate | 2 fps | 4 fps | 5 fps | | Retention of tasks and artifacts | 7 days | 30 days | 90 days | | Limit | Value | |---|---| | Active API keys | 25 per account (`409 conflict` beyond that) | | Minimum balance to create a device | 1 credit (`402 insufficient_credits`) | | Active tasks per device | 1 (`409 conflict`) | ## Sizes and lengths | Field | Limit | |---|---| | Task `instruction` | 1 to 8,000 characters (after trimming whitespace) | | Input `text` | 1 to 500 characters per message | | Input coordinates (`x`, `y`, `x1`…`y2`) | integers 0 to 10,000 | | Swipe `ms` | 1 to 10,000 (default 300) | | Device and snapshot `name`, API key `name` | up to 64 characters | | Device `profile` | `pixel_8`, `pixel_tablet` or `small_phone` | | Device `android` | 1 or 2 digits | | Upload `filename` | 5 to 128 characters, ending in `.apk` | | Upload size | 300 MB (314,572,800 bytes) | | `GET /v1/tasks` `limit` | 1 to 200 (default 50) | | `GET /v1/billing/ledger` `limit` | 1 to 500 (default 50) | | `GET /v1/devices?status=all`, `GET /v1/snapshots` | the 200 most recent | ## Timeouts | What | Value | Result | |---|---|---| | `wait` on device creation | 90 seconds maximum | Returns the device in its current status with `X-Wait-Timed-Out: 1` | | APK install | 15 minutes | Install becomes `failed` with `timeout` | | Queued device | 10 minutes | Device goes to `error` with `no_capacity` | | Idle running device | 30 minutes without activity | Device stops with reason `idle` | | Queued task | 5 minutes | Task fails with `timeout` | | Running task | 1,900 seconds | Task fails with `timeout` | | Screenshot | 8 seconds to get a frame | `504` with code `internal` | | Presigned upload URL | 15 minutes | Storage rejects the `PUT` | | Artifact download URL | 15 minutes | Request the artifact list again | | Stream token | 5 minutes, single use | Get a new token | | Dashboard session | 30 days | Sign in again | ## Retention | Data | Kept for | |---|---| | Task records, steps and artifacts (video, screenshots, trace, log) | Your plan's retention: 7, 30 or 90 days | | Uploaded APKs | 7 days | | Install records | 30 days | | Snapshots | Until you delete them, or until the host holding them is lost | | Device records | Kept; `GET /v1/devices?status=all` shows the 200 most recent | | Credit ledger | Kept; usage rows older than 90 days are combined into one row per month | | Model keys | Never stored | ## Raising limits Buying any credit pack moves you to Dev (3 devices). The Team pack gives 5 devices for 30 days. For more, contact [hello@akaphone.com](mailto:hello@akaphone.com) about an Enterprise plan. --- # REST API > Base URL, authentication with ak_live_ Bearer keys, request and response conventions, the error format with every code and HTTP status, rate limits and list limits. Source: https://akaphone-docs.pages.dev/api/rest/ The AkaPhone API is JSON over HTTPS. Everything the dashboard does goes through it, so anything you can do in the dashboard you can script. ## Base URL ```text https://akaphone.abhgnx-417.workers.dev ``` All resource endpoints are under `/v1`. `GET /healthz` and `GET /v1/profiles` work without authentication. ## Authentication Send an API key as a Bearer token: ```bash curl https://akaphone.abhgnx-417.workers.dev/v1/me -H "Authorization: Bearer ak_live_urQuj41lKvo2VYp2kBoFqRGw5ig661RoAtM5uTvyrhB" ``` - Create keys in the dashboard under **API keys**, or with [`POST /v1/keys`](https://akaphone-docs.pages.dev/api/keys/). A key is `ak_live_` followed by 43 letters and digits. It is shown once; AkaPhone stores only its SHA-256 hash. - A key has the full rights of your account. There are no scopes. Use one key per machine or pipeline so you can revoke them independently. - A missing, malformed or revoked key gets `401 unauthorized`. A suspended account gets `403 forbidden`. The dashboard authenticates with a session cookie instead. Cookie-authenticated requests that change something are accepted only from the dashboard's own origin, so the cookie cannot be used from other sites. ## Requests and responses - Send bodies as JSON with `Content-Type: application/json`. Unknown fields are ignored. A body that is not valid JSON gets `400 invalid_request` (*Request body must be valid JSON*). Endpoints whose fields are all optional accept an empty body. - Times are ISO 8601 strings in UTC, for example `"2026-10-26T09:14:02.000Z"`. Fields that have not happened yet are `null`. - IDs are prefixed strings: `usr_`, `key_`, `dev_`, `tsk_`, `snp_`, `upl_`, `led_`, `ins_`, `art_`, `nod_`. Treat them as opaque. - Money is integer **credits** (1 credit = 1 device-minute). - Successful writes return `201` (created), `202` (accepted, work continues in the background) or `204` (no body). ### Lists List endpoints return everything that matches, newest first, with no cursor: | Endpoint | Returns | Limit | |---|---|---| | `GET /v1/devices` | a JSON array | active devices; `?status=all` for the 200 most recent | | `GET /v1/snapshots` | a JSON array | the 200 most recent | | `GET /v1/tasks` | a JSON array | `?limit=` 1–200, default 50 | | `GET /v1/keys` | `{ "keys": [...] }` | all active keys (at most 25) | | `GET /v1/billing/ledger` | `{ "entries": [...] }` | `?limit=` 1–500, default 50 | | `GET /v1/tasks/:id/artifacts` | `{ "files": [...] }` | all files of the task | ## Errors Every error has the same shape: ```json { "error": { "code": "concurrency_limit", "message": "Your plan allows 1 concurrent device" } } ``` Branch on `code`; `message` is for people and may change. | HTTP | `code` | Meaning | |---|---|---| | 400 | `invalid_request` | The body or query failed validation. The message names the field, e.g. `instruction: Too small: expected string to have >=1 characters`. | | 401 | `unauthorized` | No credentials, or the key or session is invalid or revoked. | | 402 | `insufficient_credits` | Your balance is below 1 credit. Buy a pack. | | 403 | `forbidden` | The account is suspended, or a cookie request came from another origin. | | 404 | `not_found` | The resource does not exist or belongs to someone else. Also returned for unknown routes. | | 409 | `concurrency_limit` | You already have as many devices as your plan allows. | | 409 | `conflict` | The request clashes with current state: the device is not `running`, it already has an active task, a snapshot quota is reached, an upload is not complete, the device's host is not connected, too many API keys. | | 426 | `invalid_request` | A plain HTTP request to the stream endpoint, which needs a WebSocket upgrade. | | 429 | `rate_limited` | Too many requests. See `Retry-After`. | | 500 | `internal` | Something went wrong on our side. Safe to retry with backoff. | | 503 | `no_capacity` | Reserved. Devices queue instead of failing when no slot is free, so you will see `no_capacity` as a device's `error` field, not as an HTTP error. | | 504 | `internal` | A screenshot could not get a frame from the device within 8 seconds. Retry. | ## Rate limits | Bucket | Limit | |---|---| | All `/v1` requests | 120 per minute per account | | Device creation (including restores) | 10 per minute | | Task creation | 30 per minute | | Snapshot creation | 5 per hour | A limited request gets `429` with a `Retry-After` header in seconds. There are no `X-RateLimit-*` headers. A simple retry wrapper: ```typescript title="TypeScript" async function pf(path: string, init: RequestInit = {}, tries = 4): Promise { const res = await fetch(`https://akaphone.abhgnx-417.workers.dev${path}`, { ...init, headers: { Authorization: `Bearer ${process.env.AKAPHONE_API_KEY}`, "Content-Type": "application/json", ...init.headers, }, }); if (res.status === 429 && tries > 1) { const wait = Number(res.headers.get("retry-after") ?? "1"); await new Promise((r) => setTimeout(r, wait * 1000)); return pf(path, init, tries - 1); } if (res.status === 204) return null; const body = await res.json(); if (!res.ok) throw new Error(`${res.status} ${body.error.code}: ${body.error.message}`); return body; } ``` ```python title="Python" import os, time, requests API = "https://akaphone.abhgnx-417.workers.dev" session = requests.Session() session.headers["Authorization"] = f"Bearer {os.environ['AKAPHONE_API_KEY']}" def pf(method: str, path: str, tries: int = 4, **kwargs): r = session.request(method, API + path, **kwargs) if r.status_code == 429 and tries > 1: time.sleep(int(r.headers.get("Retry-After", "1"))) return pf(method, path, tries - 1, **kwargs) if r.status_code == 204: return None body = r.json() if not r.ok: raise RuntimeError(f"{r.status_code} {body['error']['code']}: {body['error']['message']}") return body ``` Plan limits (concurrent devices, snapshots, retention) are listed in [Limits and quotas](https://akaphone-docs.pages.dev/guides/limits-and-quotas/). ## CORS The API sends CORS headers only to AkaPhone's own sites. Call it from your server, scripts or CI, not from a browser page on another domain. For a browser live view on your own site, mint a [stream token](https://akaphone-docs.pages.dev/api/stream/#authentication) on your server. ## Resources | Resource | Endpoints | |---|---| | [Devices](https://akaphone-docs.pages.dev/api/devices/) | profiles, create, list, get, stop, screenshot, input, install app, install status, snapshot, stream token | | [Tasks](https://akaphone-docs.pages.dev/api/tasks/) | run, list, get, cancel, artifacts | | [Snapshots](https://akaphone-docs.pages.dev/api/snapshots/) | list, restore, delete | | [Uploads](https://akaphone-docs.pages.dev/api/uploads/) | create, complete | | [Live stream](https://akaphone-docs.pages.dev/api/stream/) | WebSocket frames, events and input | | [API keys](https://akaphone-docs.pages.dev/api/keys/) | list, create, revoke | | [Usage](https://akaphone-docs.pages.dev/api/usage/) | minutes by day and device | | [Billing](https://akaphone-docs.pages.dev/api/billing/) | ledger, checkout | | [Me](https://akaphone-docs.pages.dev/api/me/) | account, plan and balance | There is no official SDK or CLI yet; the examples use `fetch` and `requests` directly. The [MCP server](https://akaphone-docs.pages.dev/mcp/tools/) is the packaged client for coding agents. --- # Devices API > Create, list, inspect and stop cloud Android devices; take screenshots, send taps and text, install apps, snapshot, and mint stream tokens. Source: https://akaphone-docs.pages.dev/api/devices/ A device is a cloud Android phone. Read [Devices](https://akaphone-docs.pages.dev/concepts/devices/) for the lifecycle and billing rules. All examples assume your key is in `AKAPHONE_API_KEY`. ## The device object ```json { "id": "dev_s6zr7mr4f2f1", "name": "checkout-test", "status": "running", "profile": "pixel_8", "android": "14", "region": "eu-central", "node_id": "nod_01m5vqsywg84jv64rmmqf9", "snapshot_id": null, "created_at": "2026-10-26T09:14:02.000Z", "ready_at": "2026-10-26T09:14:09.000Z", "terminated_at": null, "last_activity_at": "2026-10-26T09:16:40.000Z", "billed_minutes": 3, "error": null } ``` | Field | Type | Description | |---|---|---| | `id` | string | `dev_` and 12 characters. | | `name` | string or null | Your label. | | `status` | string | `queued`, `creating`, `running`, `stopping`, `terminated`, `error` or `lost`. | | `profile` | string | Screen profile, default `pixel_8`. | | `android` | string | Android version, `"14"`. | | `region` | string | Where it runs, `eu-central`. | | `node_id` | string or null | The host it was placed on; `null` while queued. | | `snapshot_id` | string or null | The snapshot it was restored from. | | `created_at` | string | When you created it. | | `ready_at` | string or null | When it became `running`. Billing starts here. | | `terminated_at` | string or null | When it ended. | | `last_activity_at` | string | Last input, task, install or viewer activity. Used for the 30-minute idle stop. | | `billed_minutes` | integer | Credits charged for this device so far. | | `error` | string or null | Why it failed, e.g. `no_capacity`. | ## List profiles `GET /v1/profiles` The device profiles you can pass as `profile`. No authentication needed; cacheable for 5 minutes. ```bash title="curl" curl https://akaphone.abhgnx-417.workers.dev/v1/profiles ``` ```typescript title="TypeScript" const profiles = await fetch("https://akaphone.abhgnx-417.workers.dev/v1/profiles").then((r) => r.json()); ``` ```python title="Python" profiles = requests.get("https://akaphone.abhgnx-417.workers.dev/v1/profiles").json() ``` **Response** `200 OK` ```json [ { "id": "pixel_8", "name": "Pixel 8", "width": 720, "height": 1560, "dpi": 320, "android": "14" }, { "id": "pixel_tablet", "name": "Pixel Tablet", "width": 1280, "height": 800, "dpi": 240, "android": "14" }, { "id": "small_phone", "name": "Small phone", "width": 540, "height": 960, "dpi": 240, "android": "14" } ] ``` `width` and `height` are the device pixels that [input](#send-input) coordinates use. `pixel_8` is the default. ## Create a device `POST /v1/devices` Queues a new device and returns it. With `wait`, the request is held until the device is out of `queued` and `creating`, for at most 90 seconds. | Body field | Type | Default | Description | |---|---|---|---| | `name` | string | `null` | Up to 64 characters. | | `profile` | string | `"pixel_8"` | `pixel_8`, `pixel_tablet` or `small_phone`; see [list profiles](#list-profiles). | | `android` | string | `"14"` | One or two digits. | | `snapshot_id` | string | `null` | Boot from this `ready` snapshot. Same as [restore](https://akaphone-docs.pages.dev/api/snapshots/#restore-a-snapshot). | | `region` | string | first region | Must be an available region; today only `eu-central`. | | `wait` | boolean | `false` | Hold the request until the device is running (max 90 s). | The query parameters `?wait=true&timeout=` do the same as `wait`; `timeout` is capped at 90. ```bash title="curl" curl -X POST https://akaphone.abhgnx-417.workers.dev/v1/devices \ -H "Authorization: Bearer $AKAPHONE_API_KEY" \ -H "Content-Type: application/json" \ -d '{"name": "checkout-test", "wait": true}' ``` ```typescript title="TypeScript" const res = await fetch("https://akaphone.abhgnx-417.workers.dev/v1/devices", { method: "POST", headers: { Authorization: `Bearer ${process.env.AKAPHONE_API_KEY}`, "Content-Type": "application/json", }, body: JSON.stringify({ name: "checkout-test", wait: true }), }); if (!res.ok) throw new Error((await res.json()).error.message); const device = await res.json(); ``` ```python title="Python" import os, requests r = requests.post( "https://akaphone.abhgnx-417.workers.dev/v1/devices", headers={"Authorization": f"Bearer {os.environ['AKAPHONE_API_KEY']}"}, json={"name": "checkout-test", "wait": True}, ) r.raise_for_status() device = r.json() ``` **Response** `201 Created` with the [device](#the-device-object). Without `wait` its status is usually `queued`. With `wait` it is normally `running`; if boot failed it is `error`. If the 90 seconds ran out while the device was still `queued` or `creating`, the response carries the header **`X-Wait-Timed-Out: 1`**: poll [get a device](#get-a-device) until it is `running`. ```http HTTP/1.1 201 Created Content-Type: application/json X-Wait-Timed-Out: 1 {"id": "dev_s6zr7mr4f2f1", "status": "creating", "…": "…"} ``` | Error | When | |---|---| | `402 insufficient_credits` | Balance below 1 credit. | | `409 concurrency_limit` | You already have your plan's maximum of queued, creating and running devices. | | `404 not_found` | `snapshot_id` does not exist. | | `409 conflict` | The snapshot is `lost`, `pending` or `error`. | | `400 invalid_request` | A field is invalid, the region is unknown, or the profile is not one of `pixel_8`, `pixel_tablet`, `small_phone`. | | `429 rate_limited` | More than 10 device creations in a minute. | ## List devices `GET /v1/devices` Returns a JSON **array** of devices, newest first. By default only active devices (`queued`, `creating`, `running`, `stopping`). | Query | Description | |---|---| | `status=all` | Include finished devices (the 200 most recent). `all=true` is accepted too. | ```bash title="curl" curl "https://akaphone.abhgnx-417.workers.dev/v1/devices?status=all" \ -H "Authorization: Bearer $AKAPHONE_API_KEY" ``` ```typescript title="TypeScript" const devices = await fetch("https://akaphone.abhgnx-417.workers.dev/v1/devices?status=all", { headers: { Authorization: `Bearer ${process.env.AKAPHONE_API_KEY}` }, }).then((r) => r.json()); ``` ```python title="Python" devices = requests.get( "https://akaphone.abhgnx-417.workers.dev/v1/devices", params={"status": "all"}, headers={"Authorization": f"Bearer {os.environ['AKAPHONE_API_KEY']}"}, ).json() ``` **Response** `200 OK` ```json [ { "id": "dev_s6zr7mr4f2f1", "name": "checkout-test", "status": "running", "profile": "pixel_8", "android": "14", "region": "eu-central", "node_id": "nod_01m5vqsywg84jv64rmmqf9", "snapshot_id": null, "created_at": "2026-10-26T09:14:02.000Z", "ready_at": "2026-10-26T09:14:09.000Z", "terminated_at": null, "last_activity_at": "2026-10-26T09:16:40.000Z", "billed_minutes": 3, "error": null }, { "id": "dev_ynxe87v87zx0", "name": null, "status": "terminated", "profile": "pixel_8", "android": "14", "region": "eu-central", "node_id": "nod_01m5vqsywg84jv64rmmqf9", "snapshot_id": null, "created_at": "2026-10-25T17:02:11.000Z", "ready_at": "2026-10-25T17:02:19.000Z", "terminated_at": "2026-10-25T17:21:40.000Z", "last_activity_at": "2026-10-25T17:21:40.000Z", "billed_minutes": 20, "error": null } ] ``` ## Get a device `GET /v1/devices/:id` ```bash title="curl" curl https://akaphone.abhgnx-417.workers.dev/v1/devices/dev_s6zr7mr4f2f1 \ -H "Authorization: Bearer $AKAPHONE_API_KEY" ``` ```typescript title="TypeScript" const device = await fetch("https://akaphone.abhgnx-417.workers.dev/v1/devices/dev_s6zr7mr4f2f1", { headers: { Authorization: `Bearer ${process.env.AKAPHONE_API_KEY}` }, }).then((r) => r.json()); ``` ```python title="Python" device = requests.get( "https://akaphone.abhgnx-417.workers.dev/v1/devices/dev_s6zr7mr4f2f1", headers={"Authorization": f"Bearer {os.environ['AKAPHONE_API_KEY']}"}, ).json() ``` **Response** `200 OK` with the [device](#the-device-object). `404 not_found` if it does not exist or is not yours. ## Stop a device `DELETE /v1/devices/:id` Stops the device and its billing. Idempotent: stopping a device that has already ended returns it unchanged. ```bash title="curl" curl -X DELETE https://akaphone.abhgnx-417.workers.dev/v1/devices/dev_s6zr7mr4f2f1 \ -H "Authorization: Bearer $AKAPHONE_API_KEY" ``` ```typescript title="TypeScript" await fetch("https://akaphone.abhgnx-417.workers.dev/v1/devices/dev_s6zr7mr4f2f1", { method: "DELETE", headers: { Authorization: `Bearer ${process.env.AKAPHONE_API_KEY}` }, }); ``` ```python title="Python" requests.delete( "https://akaphone.abhgnx-417.workers.dev/v1/devices/dev_s6zr7mr4f2f1", headers={"Authorization": f"Bearer {os.environ['AKAPHONE_API_KEY']}"}, ) ``` **Response** `202 Accepted` with the device: `stopping` if it was creating or running (it becomes `terminated` shortly after), or `terminated` if it was still queued. ```json { "id": "dev_s6zr7mr4f2f1", "name": "checkout-test", "status": "stopping", "profile": "pixel_8", "android": "14", "region": "eu-central", "node_id": "nod_01m5vqsywg84jv64rmmqf9", "snapshot_id": null, "created_at": "2026-10-26T09:14:02.000Z", "ready_at": "2026-10-26T09:14:09.000Z", "terminated_at": null, "last_activity_at": "2026-10-26T09:31:15.000Z", "billed_minutes": 18, "error": null } ``` ## Take a screenshot `GET /v1/devices/:id/screenshot` Returns the current screen as `image/jpeg`. If someone is watching the live stream you get the latest frame; otherwise the host captures one. ```bash title="curl" curl https://akaphone.abhgnx-417.workers.dev/v1/devices/dev_s6zr7mr4f2f1/screenshot \ -H "Authorization: Bearer $AKAPHONE_API_KEY" \ -o screen.jpg ``` ```typescript title="TypeScript" import { writeFile } from "node:fs/promises"; const res = await fetch("https://akaphone.abhgnx-417.workers.dev/v1/devices/dev_s6zr7mr4f2f1/screenshot", { headers: { Authorization: `Bearer ${process.env.AKAPHONE_API_KEY}` }, }); await writeFile("screen.jpg", Buffer.from(await res.arrayBuffer())); ``` ```python title="Python" r = requests.get( "https://akaphone.abhgnx-417.workers.dev/v1/devices/dev_s6zr7mr4f2f1/screenshot", headers={"Authorization": f"Bearer {os.environ['AKAPHONE_API_KEY']}"}, ) open("screen.jpg", "wb").write(r.content) ``` **Response** `200 OK`, `Content-Type: image/jpeg`. Frames are downscaled by the host (540 px wide by default). | Error | When | |---|---| | `409 conflict` | The device is not `running`. | | `504 internal` | No frame arrived within 8 seconds. Retry. | ## Send input `POST /v1/devices/:id/input` Sends one tap, swipe, text or key event. Coordinates are in device pixels (720×1560 for `pixel_8`). | `type` | Fields | |---|---| | `tap` | `x`, `y`: integers 0–10000 | | `swipe` | `x1`, `y1`, `x2`, `y2`: integers 0–10000; `ms`: 1–10000, default 300 | | `text` | `text`: 1–500 characters | | `key` | `key`: `HOME`, `BACK`, `RECENTS`, `ENTER`, `DEL` or `TAB` | ```bash title="curl" curl -X POST https://akaphone.abhgnx-417.workers.dev/v1/devices/dev_s6zr7mr4f2f1/input \ -H "Authorization: Bearer $AKAPHONE_API_KEY" \ -H "Content-Type: application/json" \ -d '{"type": "tap", "x": 360, "y": 780}' ``` ```typescript title="TypeScript" const input = (body: object) => fetch("https://akaphone.abhgnx-417.workers.dev/v1/devices/dev_s6zr7mr4f2f1/input", { method: "POST", headers: { Authorization: `Bearer ${process.env.AKAPHONE_API_KEY}`, "Content-Type": "application/json" }, body: JSON.stringify(body), }); await input({ type: "tap", x: 360, y: 780 }); await input({ type: "text", text: "hello@example.com" }); await input({ type: "swipe", x1: 360, y1: 1200, x2: 360, y2: 400, ms: 250 }); await input({ type: "key", key: "ENTER" }); ``` ```python title="Python" def send_input(body: dict): return requests.post( "https://akaphone.abhgnx-417.workers.dev/v1/devices/dev_s6zr7mr4f2f1/input", headers={"Authorization": f"Bearer {os.environ['AKAPHONE_API_KEY']}"}, json=body, ) send_input({"type": "tap", "x": 360, "y": 780}) send_input({"type": "text", "text": "hello@example.com"}) send_input({"type": "key", "key": "ENTER"}) ``` **Response** `202 Accepted` ```json { "ok": true } ``` The event is sent to the device; there is no confirmation that it had an effect. Take a screenshot to check. For many events in a row, the [stream socket](https://akaphone-docs.pages.dev/api/stream/#sending-input) is faster than one HTTP request each. | Error | When | |---|---| | `400 invalid_request` | Unknown `type`, missing field, or value out of range. | | `409 conflict` | The device is not `running`, or its host is not connected. | ## Install an app `POST /v1/devices/:id/apps` Installs a completed [upload](https://akaphone-docs.pages.dev/api/uploads/) on the device. | Body field | Type | Description | |---|---|---| | `upload_id` | string | An upload in status `uploaded`. | ```bash title="curl" curl -X POST https://akaphone.abhgnx-417.workers.dev/v1/devices/dev_s6zr7mr4f2f1/apps \ -H "Authorization: Bearer $AKAPHONE_API_KEY" \ -H "Content-Type: application/json" \ -d '{"upload_id": "upl_01m5vqsywgw57haqkjnexz"}' ``` ```typescript title="TypeScript" const { install_id } = await fetch("https://akaphone.abhgnx-417.workers.dev/v1/devices/dev_s6zr7mr4f2f1/apps", { method: "POST", headers: { Authorization: `Bearer ${process.env.AKAPHONE_API_KEY}`, "Content-Type": "application/json" }, body: JSON.stringify({ upload_id: "upl_01m5vqsywgw57haqkjnexz" }), }).then((r) => r.json()); ``` ```python title="Python" install_id = requests.post( "https://akaphone.abhgnx-417.workers.dev/v1/devices/dev_s6zr7mr4f2f1/apps", headers={"Authorization": f"Bearer {os.environ['AKAPHONE_API_KEY']}"}, json={"upload_id": "upl_01m5vqsywgw57haqkjnexz"}, ).json()["install_id"] ``` **Response** `202 Accepted` ```json { "install_id": "ins_01m5vqsywg99189k5xj7f5" } ``` The host downloads and installs the APK in the background (`adb install -r -g`). Poll [get an install](#get-an-install) for the outcome, or watch for the [`app.installed`](https://akaphone-docs.pages.dev/api/stream/#events) event on the live stream. See [Install an APK](https://akaphone-docs.pages.dev/guides/install-apk/). | Error | When | |---|---| | `404 not_found` | Unknown upload. | | `409 conflict` | The upload is not complete, the device is not `running`, or its host is not connected. | ## Get an install `GET /v1/devices/:id/apps/:install_id` The status of an install started with [install an app](#install-an-app). Poll it every few seconds until `status` is no longer `pending`. ```bash title="curl" curl https://akaphone.abhgnx-417.workers.dev/v1/devices/dev_s6zr7mr4f2f1/apps/ins_01m5vqsywg99189k5xj7f5 \ -H "Authorization: Bearer $AKAPHONE_API_KEY" ``` ```typescript title="TypeScript" const install = await fetch("https://akaphone.abhgnx-417.workers.dev/v1/devices/dev_s6zr7mr4f2f1/apps/ins_01m5vqsywg99189k5xj7f5", { headers: { Authorization: `Bearer ${process.env.AKAPHONE_API_KEY}` }, }).then((r) => r.json()); ``` ```python title="Python" install = requests.get( "https://akaphone.abhgnx-417.workers.dev/v1/devices/dev_s6zr7mr4f2f1/apps/ins_01m5vqsywg99189k5xj7f5", headers={"Authorization": f"Bearer {os.environ['AKAPHONE_API_KEY']}"}, ).json() ``` **Response** `200 OK` ```json { "install_id": "ins_01m5vqsywg99189k5xj7f5", "status": "installed", "package": "com.example.shop", "created_at": "2026-10-26T09:20:00.000Z", "finished_at": "2026-10-26T09:20:06.000Z" } ``` | Field | Description | |---|---| | `status` | `pending`, `installed` or `failed`. | | `package` | The installed package name, when it could be determined. Omitted otherwise. | | `error` | Why it failed, e.g. `INSTALL_FAILED_NO_MATCHING_ABIS` or `timeout`. Omitted on success. | | `finished_at` | When it ended. Omitted while `pending`. | An install still `pending` after 15 minutes becomes `failed` with `error: "timeout"`. Install records are kept for 30 days. `404 not_found` for an unknown device or install id. ## Snapshot a device `POST /v1/devices/:id/snapshot` Saves the device's disk as a [snapshot](https://akaphone-docs.pages.dev/concepts/snapshots/). | Body field | Type | Description | |---|---|---| | `name` | string | Optional, up to 64 characters. | ```bash title="curl" curl -X POST https://akaphone.abhgnx-417.workers.dev/v1/devices/dev_s6zr7mr4f2f1/snapshot \ -H "Authorization: Bearer $AKAPHONE_API_KEY" \ -H "Content-Type: application/json" \ -d '{"name": "signed-in-v2.3"}' ``` ```typescript title="TypeScript" const snapshot = await fetch("https://akaphone.abhgnx-417.workers.dev/v1/devices/dev_s6zr7mr4f2f1/snapshot", { method: "POST", headers: { Authorization: `Bearer ${process.env.AKAPHONE_API_KEY}`, "Content-Type": "application/json" }, body: JSON.stringify({ name: "signed-in-v2.3" }), }).then((r) => r.json()); ``` ```python title="Python" snapshot = requests.post( "https://akaphone.abhgnx-417.workers.dev/v1/devices/dev_s6zr7mr4f2f1/snapshot", headers={"Authorization": f"Bearer {os.environ['AKAPHONE_API_KEY']}"}, json={"name": "signed-in-v2.3"}, ).json() ``` **Response** `202 Accepted` ```json { "id": "snp_01m5vqsywgez2ntdv5fq42", "device_id": "dev_s6zr7mr4f2f1", "node_id": "nod_01m5vqsywg84jv64rmmqf9", "name": "signed-in-v2.3", "size_bytes": null, "status": "pending", "created_at": "2026-10-26T09:20:44.000Z" } ``` | Error | When | |---|---| | `409 conflict` | The device is not `running`; your snapshot quota is reached; the host is low on disk or not connected. | | `429 rate_limited` | More than 5 snapshots in an hour. | ## Create a stream token `POST /v1/devices/:id/stream-token` Returns a single-use token, valid for 5 minutes, that lets a browser open this device's [live stream](https://akaphone-docs.pages.dev/api/stream/) without your API key. Call it from your server and hand the token to the page. ```bash title="curl" curl -X POST https://akaphone.abhgnx-417.workers.dev/v1/devices/dev_s6zr7mr4f2f1/stream-token \ -H "Authorization: Bearer $AKAPHONE_API_KEY" ``` ```typescript title="TypeScript" const { token } = await fetch("https://akaphone.abhgnx-417.workers.dev/v1/devices/dev_s6zr7mr4f2f1/stream-token", { method: "POST", headers: { Authorization: `Bearer ${process.env.AKAPHONE_API_KEY}` }, }).then((r) => r.json()); ``` ```python title="Python" token = requests.post( "https://akaphone.abhgnx-417.workers.dev/v1/devices/dev_s6zr7mr4f2f1/stream-token", headers={"Authorization": f"Bearer {os.environ['AKAPHONE_API_KEY']}"}, ).json()["token"] ``` **Response** `200 OK` ```json { "token": "pfs_Jq3w0kV9mXr2YbT6nLc8Hd1sPfAe4uZo" } ``` The token is bound to this device. It works for one connection; reconnecting needs a new token. ## Related - [Run a task on a device](https://akaphone-docs.pages.dev/api/tasks/#run-a-task): `POST /v1/devices/:id/tasks` - [Live stream](https://akaphone-docs.pages.dev/api/stream/): `GET /v1/devices/:id/stream` (WebSocket) - [Restore a snapshot](https://akaphone-docs.pages.dev/api/snapshots/#restore-a-snapshot): `POST /v1/snapshots/:id/restore` --- # Tasks API > Run plain-English tasks on a device with your model key, poll their status and steps, cancel them, and download their video, screenshots, trace and log. Source: https://akaphone-docs.pages.dev/api/tasks/ A task is a plain-English instruction carried out by the ARTEMIS agent on a running device. Read [Tasks](https://akaphone-docs.pages.dev/concepts/tasks/) for profiles, statuses and timeouts. ## The task object ```json { "id": "tsk_01m5vqsywgehe4jhva3g0r", "device_id": "dev_s6zr7mr4f2f1", "instruction": "Open Settings > About phone and report the Android version", "profile": "flash", "provider": "gemini", "status": "succeeded", "result": "Android version: 14", "error": null, "step_count": 4, "created_at": "2026-10-26T09:15:10.000Z", "started_at": "2026-10-26T09:15:11.000Z", "ended_at": "2026-10-26T09:15:29.000Z" } ``` | Field | Type | Description | |---|---|---| | `id` | string | `tsk_…` | | `device_id` | string | The device it runs on. | | `instruction` | string | What you asked for. | | `profile` | string | `flash` or `pro`. | | `provider` | string or null | The `X-Model-Provider` you sent, or `null` (Gemini is used). | | `status` | string | `queued`, `running`, `succeeded`, `failed` or `cancelled`. | | `result` | string or null | The agent's final answer when it finishes. | | `error` | string or null | Why it failed, e.g. `timeout`. | | `step_count` | integer | Steps taken so far. | | `steps` | array | Only on [get](#get-a-task) and [cancel](#cancel-a-task): `[{ "n", "label", "ms" }]`. | | `created_at`, `started_at`, `ended_at` | string or null | Timestamps. | ## Run a task `POST /v1/devices/:id/tasks` Starts a task on a `running` device. Returns immediately; poll [get a task](#get-a-task) or watch the [stream](https://akaphone-docs.pages.dev/api/stream/#events) for progress. | Header | Required | Description | |---|---|---| | `X-Model-Key` | yes, in practice | Your model provider key. Used for this task only and never stored. | | `X-Model-Provider` | no | `gemini` (default), `anthropic` or `openai`. | | Body field | Type | Default | Description | |---|---|---|---| | `instruction` | string | required | 1–8,000 characters, in plain English. | | `profile` | string | `"flash"` | `flash` for short, direct actions; `pro` for multi-step flows. | ```bash title="curl" curl -X POST https://akaphone.abhgnx-417.workers.dev/v1/devices/dev_s6zr7mr4f2f1/tasks \ -H "Authorization: Bearer $AKAPHONE_API_KEY" \ -H "X-Model-Key: $GEMINI_API_KEY" \ -H "X-Model-Provider: gemini" \ -H "Content-Type: application/json" \ -d '{"instruction": "Open Settings > About phone and report the Android version", "profile": "flash"}' ``` ```typescript title="TypeScript" const res = await fetch("https://akaphone.abhgnx-417.workers.dev/v1/devices/dev_s6zr7mr4f2f1/tasks", { method: "POST", headers: { Authorization: `Bearer ${process.env.AKAPHONE_API_KEY}`, "X-Model-Key": process.env.GEMINI_API_KEY!, "X-Model-Provider": "gemini", "Content-Type": "application/json", }, body: JSON.stringify({ instruction: "Open Settings > About phone and report the Android version", profile: "flash", }), }); const task = await res.json(); ``` ```python title="Python" import os, requests task = requests.post( "https://akaphone.abhgnx-417.workers.dev/v1/devices/dev_s6zr7mr4f2f1/tasks", headers={ "Authorization": f"Bearer {os.environ['AKAPHONE_API_KEY']}", "X-Model-Key": os.environ["GEMINI_API_KEY"], "X-Model-Provider": "gemini", }, json={"instruction": "Open Settings > About phone and report the Android version", "profile": "flash"}, ).json() ``` **Response** `202 Accepted` ```json { "id": "tsk_01m5vqsywgehe4jhva3g0r", "device_id": "dev_s6zr7mr4f2f1", "instruction": "Open Settings > About phone and report the Android version", "profile": "flash", "provider": "gemini", "status": "queued", "result": null, "error": null, "step_count": 0, "created_at": "2026-10-26T09:15:10.000Z", "started_at": null, "ended_at": null } ``` | Error | When | |---|---| | `404 not_found` | Unknown device. | | `409 conflict` | The device is not `running`; it already has a `queued` or `running` task (the message includes its id); or its host is not connected. | | `400 invalid_request` | Empty or too long `instruction`, unknown `profile`, or `X-Model-Provider` not one of the three. | | `429 rate_limited` | More than 30 tasks in a minute. | ## Wait for a result There is no blocking variant; poll every few seconds until the status is final. ```bash title="curl" while :; do TASK=$(curl -s https://akaphone.abhgnx-417.workers.dev/v1/tasks/$TASK_ID -H "Authorization: Bearer $AKAPHONE_API_KEY") case $(echo "$TASK" | jq -r .status) in succeeded|failed|cancelled) break;; esac sleep 3 done echo "$TASK" | jq '{status, result, error}' ``` ```typescript title="TypeScript" async function waitForTask(id: string, timeoutMs = 35 * 60_000) { const deadline = Date.now() + timeoutMs; while (Date.now() < deadline) { const t = await fetch(`https://akaphone.abhgnx-417.workers.dev/v1/tasks/${id}`, { headers: { Authorization: `Bearer ${process.env.AKAPHONE_API_KEY}` }, }).then((r) => r.json()); if (["succeeded", "failed", "cancelled"].includes(t.status)) return t; await new Promise((r) => setTimeout(r, 3000)); } throw new Error(`task ${id} still running`); } ``` ```python title="Python" import time def wait_for_task(task_id: str, timeout_s: int = 35 * 60): deadline = time.time() + timeout_s while time.time() < deadline: t = requests.get( f"https://akaphone.abhgnx-417.workers.dev/v1/tasks/{task_id}", headers={"Authorization": f"Bearer {os.environ['AKAPHONE_API_KEY']}"}, ).json() if t["status"] in ("succeeded", "failed", "cancelled"): return t time.sleep(3) raise TimeoutError(task_id) ``` Polling every 3 seconds is 20 requests a minute, well inside the 120 per minute limit even with a few devices. ## List tasks `GET /v1/tasks` Returns a JSON **array** of tasks, newest first, without `steps`. | Query | Description | |---|---| | `device_id` | Only tasks on this device. | | `limit` | 1–200, default 50. | ```bash title="curl" curl "https://akaphone.abhgnx-417.workers.dev/v1/tasks?device_id=dev_s6zr7mr4f2f1&limit=10" \ -H "Authorization: Bearer $AKAPHONE_API_KEY" ``` ```typescript title="TypeScript" const tasks = await fetch("https://akaphone.abhgnx-417.workers.dev/v1/tasks?device_id=dev_s6zr7mr4f2f1&limit=10", { headers: { Authorization: `Bearer ${process.env.AKAPHONE_API_KEY}` }, }).then((r) => r.json()); ``` ```python title="Python" tasks = requests.get( "https://akaphone.abhgnx-417.workers.dev/v1/tasks", params={"device_id": "dev_s6zr7mr4f2f1", "limit": 10}, headers={"Authorization": f"Bearer {os.environ['AKAPHONE_API_KEY']}"}, ).json() ``` **Response** `200 OK` ```json [ { "id": "tsk_01m5vqsywgp6df4zpzd4h9", "device_id": "dev_s6zr7mr4f2f1", "instruction": "Turn on Dark theme in Settings > Display", "profile": "flash", "provider": null, "status": "running", "result": null, "error": null, "step_count": 2, "created_at": "2026-10-26T09:18:01.000Z", "started_at": "2026-10-26T09:18:02.000Z", "ended_at": null }, { "id": "tsk_01m5vqsywgehe4jhva3g0r", "device_id": "dev_s6zr7mr4f2f1", "instruction": "Open Settings > About phone and report the Android version", "profile": "flash", "provider": "gemini", "status": "succeeded", "result": "Android version: 14", "error": null, "step_count": 4, "created_at": "2026-10-26T09:15:10.000Z", "started_at": "2026-10-26T09:15:11.000Z", "ended_at": "2026-10-26T09:15:29.000Z" } ] ``` Tasks older than your plan's retention (7, 30 or 90 days) are deleted. ## Get a task `GET /v1/tasks/:id` ```bash title="curl" curl https://akaphone.abhgnx-417.workers.dev/v1/tasks/tsk_01m5vqsywgehe4jhva3g0r \ -H "Authorization: Bearer $AKAPHONE_API_KEY" ``` ```typescript title="TypeScript" const task = await fetch("https://akaphone.abhgnx-417.workers.dev/v1/tasks/tsk_01m5vqsywgehe4jhva3g0r", { headers: { Authorization: `Bearer ${process.env.AKAPHONE_API_KEY}` }, }).then((r) => r.json()); ``` ```python title="Python" task = requests.get( "https://akaphone.abhgnx-417.workers.dev/v1/tasks/tsk_01m5vqsywgehe4jhva3g0r", headers={"Authorization": f"Bearer {os.environ['AKAPHONE_API_KEY']}"}, ).json() ``` **Response** `200 OK`: the task with its `steps`. ```json { "id": "tsk_01m5vqsywgehe4jhva3g0r", "device_id": "dev_s6zr7mr4f2f1", "instruction": "Open Settings > About phone and report the Android version", "profile": "flash", "provider": "gemini", "status": "succeeded", "result": "Android version: 14", "error": null, "step_count": 4, "created_at": "2026-10-26T09:15:10.000Z", "started_at": "2026-10-26T09:15:11.000Z", "ended_at": "2026-10-26T09:15:29.000Z", "steps": [ { "n": 1, "label": "Open the Settings app", "ms": 2140 }, { "n": 2, "label": "Scroll to About phone", "ms": 1920 }, { "n": 3, "label": "Tap About phone", "ms": 1610 }, { "n": 4, "label": "Read the Android version", "ms": 2380 } ] } ``` ## Cancel a task `POST /v1/tasks/:id/cancel` Cancels a `queued` or `running` task and stops ARTEMIS on the host. Cancelling a task that has already finished changes nothing and returns it as it is. ```bash title="curl" curl -X POST https://akaphone.abhgnx-417.workers.dev/v1/tasks/tsk_01m5vqsywgp6df4zpzd4h9/cancel \ -H "Authorization: Bearer $AKAPHONE_API_KEY" ``` ```typescript title="TypeScript" const task = await fetch("https://akaphone.abhgnx-417.workers.dev/v1/tasks/tsk_01m5vqsywgp6df4zpzd4h9/cancel", { method: "POST", headers: { Authorization: `Bearer ${process.env.AKAPHONE_API_KEY}` }, }).then((r) => r.json()); ``` ```python title="Python" task = requests.post( "https://akaphone.abhgnx-417.workers.dev/v1/tasks/tsk_01m5vqsywgp6df4zpzd4h9/cancel", headers={"Authorization": f"Bearer {os.environ['AKAPHONE_API_KEY']}"}, ).json() ``` **Response** `200 OK`: the task with `steps`, status `cancelled`. ```json { "id": "tsk_01m5vqsywgp6df4zpzd4h9", "device_id": "dev_s6zr7mr4f2f1", "instruction": "Turn on Dark theme in Settings > Display", "profile": "flash", "provider": null, "status": "cancelled", "result": null, "error": null, "step_count": 2, "created_at": "2026-10-26T09:18:01.000Z", "started_at": "2026-10-26T09:18:02.000Z", "ended_at": "2026-10-26T09:18:09.000Z", "steps": [{ "n": 1, "label": "Open the Settings app", "ms": 2050 }, { "n": 2, "label": "Tap Display", "ms": 1700 }] } ``` ## List artifacts `GET /v1/tasks/:id/artifacts` Lists the files the host uploaded for a finished task, each with a presigned download URL valid for **15 minutes**. Request the list again for fresh URLs. ```bash title="curl" curl https://akaphone.abhgnx-417.workers.dev/v1/tasks/tsk_01m5vqsywgehe4jhva3g0r/artifacts \ -H "Authorization: Bearer $AKAPHONE_API_KEY" \ | jq -r '.files[] | "\(.name) \(.url)"' \ | while read -r NAME URL; do curl -s -o "$NAME" "$URL"; done ``` ```typescript title="TypeScript" const { files } = await fetch("https://akaphone.abhgnx-417.workers.dev/v1/tasks/tsk_01m5vqsywgehe4jhva3g0r/artifacts", { headers: { Authorization: `Bearer ${process.env.AKAPHONE_API_KEY}` }, }).then((r) => r.json()); const video = files.find((f: { kind: string }) => f.kind === "video"); ``` ```python title="Python" files = requests.get( "https://akaphone.abhgnx-417.workers.dev/v1/tasks/tsk_01m5vqsywgehe4jhva3g0r/artifacts", headers={"Authorization": f"Bearer {os.environ['AKAPHONE_API_KEY']}"}, ).json()["files"] for f in files: open(f["name"], "wb").write(requests.get(f["url"]).content) ``` **Response** `200 OK` ```json { "files": [ { "id": "art_01m5vqsywgjpffndevq1tx", "kind": "video", "name": "video.mp4", "size_bytes": 3482113, "url": "https://.r2.cloudflarestorage.com/akaphone-artifacts/artifacts/usr_01m5vqsywg3mxj7dyxv9q9/tsk_01m5vqsywgehe4jhva3g0r/video.mp4?X-Amz-Algorithm=AWS4-HMAC-SHA256&..." }, { "id": "art_01m5vqsywgxexz9wabrvn9", "kind": "screenshot", "name": "before.png", "size_bytes": 412877, "url": "https://…/before.png?…" }, { "id": "art_01m5vqsywg7jzjtbbkkczn", "kind": "screenshot", "name": "after.png", "size_bytes": 398120, "url": "https://…/after.png?…" }, { "id": "art_01m5vqsywgskq594xgaqcm", "kind": "log", "name": "stdout.log", "size_bytes": 18233, "url": "https://…/stdout.log?…" } ] } ``` `kind` is `video`, `screenshot`, `trace` or `log`. The list is empty while a task is running, and for a few seconds after it ends while the host finishes uploading. Download URLs need no `Authorization` header; do not send yours to them. Artifacts are deleted after your plan's retention period. --- # Snapshots API > List snapshots, boot new devices from them, and delete them. Snapshots are created from a device with POST /v1/devices/:id/snapshot. Source: https://akaphone-docs.pages.dev/api/snapshots/ Read [Snapshots](https://akaphone-docs.pages.dev/concepts/snapshots/) first: snapshots are pinned to the host that took them, have per-plan quotas, and can be `lost`. To create one, use [`POST /v1/devices/:id/snapshot`](https://akaphone-docs.pages.dev/api/devices/#snapshot-a-device). ## The snapshot object ```json { "id": "snp_01m5vqsywgez2ntdv5fq42", "device_id": "dev_s6zr7mr4f2f1", "node_id": "nod_01m5vqsywg84jv64rmmqf9", "name": "signed-in-v2.3", "size_bytes": 1873215488, "status": "ready", "created_at": "2026-10-26T09:20:44.000Z" } ``` | Field | Type | Description | |---|---|---| | `id` | string | `snp_…` | | `device_id` | string | The device it was taken from. | | `node_id` | string | The host that stores it. Restored devices are placed on this host. | | `name` | string or null | Your label. | | `size_bytes` | integer or null | Size on disk, once `ready`. | | `status` | string | `pending`, `ready`, `error` or `lost`. | | `created_at` | string | When it was taken. | ## List snapshots `GET /v1/snapshots` Returns a JSON **array** of your snapshots (the 200 most recent, deleted ones excluded), newest first. ```bash title="curl" curl https://akaphone.abhgnx-417.workers.dev/v1/snapshots \ -H "Authorization: Bearer $AKAPHONE_API_KEY" ``` ```typescript title="TypeScript" const snapshots = await fetch("https://akaphone.abhgnx-417.workers.dev/v1/snapshots", { headers: { Authorization: `Bearer ${process.env.AKAPHONE_API_KEY}` }, }).then((r) => r.json()); const ready = snapshots.filter((s: { status: string }) => s.status === "ready"); ``` ```python title="Python" import os, requests snapshots = requests.get( "https://akaphone.abhgnx-417.workers.dev/v1/snapshots", headers={"Authorization": f"Bearer {os.environ['AKAPHONE_API_KEY']}"}, ).json() ``` **Response** `200 OK` ```json [ { "id": "snp_01m5vqsywgez2ntdv5fq42", "device_id": "dev_s6zr7mr4f2f1", "node_id": "nod_01m5vqsywg84jv64rmmqf9", "name": "signed-in-v2.3", "size_bytes": 1873215488, "status": "ready", "created_at": "2026-10-26T09:20:44.000Z" } ] ``` ## Restore a snapshot `POST /v1/snapshots/:id/restore` Creates a **new device** from a `ready` snapshot. Equivalent to [`POST /v1/devices`](https://akaphone-docs.pages.dev/api/devices/#create-a-device) with `snapshot_id`, and subject to the same credit, concurrency and rate limits. | Body field | Type | Default | Description | |---|---|---|---| | `name` | string | `null` | Name for the new device. | | `profile` | string | `"pixel_8"` | Screen profile for the new device. | | `android` | string | `"14"` | Android version. | | `wait` | boolean | `false` | Hold the request until the device is running (max 90 s). | `?wait=true&timeout=` works too. ```bash title="curl" curl -X POST https://akaphone.abhgnx-417.workers.dev/v1/snapshots/snp_01m5vqsywgez2ntdv5fq42/restore \ -H "Authorization: Bearer $AKAPHONE_API_KEY" \ -H "Content-Type: application/json" \ -d '{"name": "run-1842", "wait": true}' ``` ```typescript title="TypeScript" const device = await fetch("https://akaphone.abhgnx-417.workers.dev/v1/snapshots/snp_01m5vqsywgez2ntdv5fq42/restore", { method: "POST", headers: { Authorization: `Bearer ${process.env.AKAPHONE_API_KEY}`, "Content-Type": "application/json" }, body: JSON.stringify({ name: "run-1842", wait: true }), }).then((r) => r.json()); ``` ```python title="Python" device = requests.post( "https://akaphone.abhgnx-417.workers.dev/v1/snapshots/snp_01m5vqsywgez2ntdv5fq42/restore", headers={"Authorization": f"Bearer {os.environ['AKAPHONE_API_KEY']}"}, json={"name": "run-1842", "wait": True}, ).json() ``` **Response** `201 Created` with the new [device](https://akaphone-docs.pages.dev/api/devices/#the-device-object): ```json { "id": "dev_qj196bgdqs10", "name": "run-1842", "status": "running", "profile": "pixel_8", "android": "14", "region": "eu-central", "node_id": "nod_01m5vqsywg84jv64rmmqf9", "snapshot_id": "snp_01m5vqsywgez2ntdv5fq42", "created_at": "2026-10-26T10:02:17.000Z", "ready_at": "2026-10-26T10:02:21.000Z", "terminated_at": null, "last_activity_at": "2026-10-26T10:02:17.000Z", "billed_minutes": 1, "error": null } ``` | Error | When | |---|---| | `404 not_found` | Unknown or deleted snapshot. | | `409 conflict` | The snapshot is `lost` (*Snapshot was lost with the host that stored it and cannot be restored*), or is `pending` or `error`. | | `402 insufficient_credits` | Balance below 1. | | `409 concurrency_limit` | Plan device limit reached. | | `429 rate_limited` | More than 10 device creations in a minute. | If the snapshot's host is full, the device stays `queued` until a slot frees up there, and goes to `error` with `no_capacity` after 10 minutes. ## Delete a snapshot `DELETE /v1/snapshots/:id` Deletes the snapshot and frees its quota slot. The host removes the image in the background. Devices already restored from it keep running. ```bash title="curl" curl -X DELETE https://akaphone.abhgnx-417.workers.dev/v1/snapshots/snp_01m5vqsywgez2ntdv5fq42 \ -H "Authorization: Bearer $AKAPHONE_API_KEY" ``` ```typescript title="TypeScript" await fetch("https://akaphone.abhgnx-417.workers.dev/v1/snapshots/snp_01m5vqsywgez2ntdv5fq42", { method: "DELETE", headers: { Authorization: `Bearer ${process.env.AKAPHONE_API_KEY}` }, }); ``` ```python title="Python" requests.delete( "https://akaphone.abhgnx-417.workers.dev/v1/snapshots/snp_01m5vqsywgez2ntdv5fq42", headers={"Authorization": f"Bearer {os.environ['AKAPHONE_API_KEY']}"}, ) ``` **Response** `204 No Content`. `404 not_found` if it does not exist or was already deleted. --- # Uploads API > Upload an APK straight to storage with a presigned PUT URL, confirm it, then install it on any device for 7 days. Source: https://akaphone-docs.pages.dev/api/uploads/ Uploads move APK files from you to AkaPhone storage without passing through the API. The full walk-through, including the install step and a one-file script, is in [Install an APK](https://akaphone-docs.pages.dev/guides/install-apk/). ## Create an upload `POST /v1/uploads` Declares a file and returns a presigned URL to `PUT` it to. | Body field | Type | Default | Description | |---|---|---|---| | `filename` | string | required | 5–128 characters: letters, digits, `.`, `_`, `-`, spaces; must start with a letter or digit and end in `.apk`. Spaces become `_`. Android App Bundles (`.aab`) are rejected; build a universal APK. | | `size_bytes` | integer | required | Exact file size in bytes; at most 314,572,800 (300 MB). | | `content_type` | string | `"application/vnd.android.package-archive"` | The `Content-Type` you will `PUT` with. | ```bash title="curl" curl -X POST https://akaphone.abhgnx-417.workers.dev/v1/uploads \ -H "Authorization: Bearer $AKAPHONE_API_KEY" \ -H "Content-Type: application/json" \ -d '{"filename": "app-release.apk", "size_bytes": 48211337}' ``` ```typescript title="TypeScript" import { stat } from "node:fs/promises"; const { size } = await stat("app-release.apk"); const upload = await fetch("https://akaphone.abhgnx-417.workers.dev/v1/uploads", { method: "POST", headers: { Authorization: `Bearer ${process.env.AKAPHONE_API_KEY}`, "Content-Type": "application/json" }, body: JSON.stringify({ filename: "app-release.apk", size_bytes: size }), }).then((r) => r.json()); ``` ```python title="Python" import os, requests upload = requests.post( "https://akaphone.abhgnx-417.workers.dev/v1/uploads", headers={"Authorization": f"Bearer {os.environ['AKAPHONE_API_KEY']}"}, json={"filename": "app-release.apk", "size_bytes": os.path.getsize("app-release.apk")}, ).json() ``` **Response** `201 Created` ```json { "upload": { "id": "upl_01m5vqsywgw57haqkjnexz", "filename": "app-release.apk", "size_bytes": 48211337, "status": "pending" }, "put_url": "https://.r2.cloudflarestorage.com/akaphone-artifacts/apks/usr_01m5vqsywg3mxj7dyxv9q9/upl_01m5vqsywgw57haqkjnexz/app-release.apk?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Expires=900&...", "headers": { "content-type": "application/vnd.android.package-archive" }, "expires_in": 900 } ``` | Error | When | |---|---| | `400 invalid_request` | Bad `filename`, including `.aab` bundles (*filename must be a plain .apk file name …*), or `size_bytes` missing, not positive, or over 300 MB (*APK must be at most 300 MB*). | ## Upload the file `PUT` the bytes to `put_url` within `expires_in` seconds, sending each header in `headers` and exactly `size_bytes` bytes. This request goes to storage, not to the API: do not add your `Authorization` header. ```bash title="curl" curl -X PUT "$PUT_URL" \ -H "Content-Type: application/vnd.android.package-archive" \ --data-binary @app-release.apk ``` ```typescript title="TypeScript" import { readFile } from "node:fs/promises"; const put = await fetch(upload.put_url, { method: "PUT", headers: upload.headers, body: await readFile("app-release.apk"), }); if (!put.ok) throw new Error(`upload failed: ${put.status}`); ``` ```python title="Python" with open("app-release.apk", "rb") as f: requests.put(upload["put_url"], headers=upload["headers"], data=f).raise_for_status() ``` Storage answers `200` on success. A `403` usually means the URL expired, or the `Content-Type` or length differs from what you declared. ## Complete an upload `POST /v1/uploads/:id/complete` Confirms the object is in storage and marks the upload `uploaded`. Idempotent. ```bash title="curl" curl -X POST https://akaphone.abhgnx-417.workers.dev/v1/uploads/upl_01m5vqsywgw57haqkjnexz/complete \ -H "Authorization: Bearer $AKAPHONE_API_KEY" ``` ```typescript title="TypeScript" await fetch(`https://akaphone.abhgnx-417.workers.dev/v1/uploads/${upload.upload.id}/complete`, { method: "POST", headers: { Authorization: `Bearer ${process.env.AKAPHONE_API_KEY}` }, }); ``` ```python title="Python" requests.post( f"https://akaphone.abhgnx-417.workers.dev/v1/uploads/{upload['upload']['id']}/complete", headers={"Authorization": f"Bearer {os.environ['AKAPHONE_API_KEY']}"}, ).raise_for_status() ``` **Response** `200 OK` ```json { "upload": { "id": "upl_01m5vqsywgw57haqkjnexz", "status": "uploaded" } } ``` | Error | When | |---|---| | `404 not_found` | Unknown upload. | | `409 conflict` | The file is not in storage yet (*Object not found in storage: upload the file to put_url first*). | | `400 invalid_request` | The stored file is over 300 MB; it is deleted. | ## Install it Pass the upload id to [`POST /v1/devices/:id/apps`](https://akaphone-docs.pages.dev/api/devices/#install-an-app). One upload can be installed on any number of devices. Uploads are deleted after **7 days**. --- # Live stream API > The device WebSocket - authentication with a key, cookie or single-use stream token, binary JPEG frames, JSON events, input messages and limits. Source: https://akaphone-docs.pages.dev/api/stream/ Each device has a WebSocket that carries its screen as JPEG frames, events about the device and its tasks, and input from you. The dashboard's live view is built on it. Background in [Live streaming](https://akaphone-docs.pages.dev/concepts/streaming/). ## Connect `WS /v1/devices/:id/stream` ```text wss://akaphone.abhgnx-417.workers.dev/v1/devices/dev_s6zr7mr4f2f1/stream ``` A plain HTTP request (no WebSocket upgrade) gets `426` with code `invalid_request`. You can connect to a device in any status; frames flow only while it is `running`. ### Authentication Use one of: | Method | Use it from | |---|---| | `Authorization: Bearer ak_live_…` header on the upgrade request | Servers, scripts, Node.js and Python clients. | | `?key=` query parameter (`?token=` also works) | Browsers. Get the token from your server with [`POST /v1/devices/:id/stream-token`](https://akaphone-docs.pages.dev/api/devices/#create-a-stream-token). It is valid for 5 minutes, for this device only, and for a single connection. | | The dashboard's session cookie | The AkaPhone dashboard only. | Failures are returned before the upgrade, as normal JSON errors: `401 unauthorized` (no or bad credentials, or a used or expired token), `403 forbidden` (suspended account), `404 not_found` (not your device). ## Server to client ### Frames **Binary messages** are complete JPEG images of the screen, with no prefix or header. Draw each one as it arrives. - Rate: 2 fps on Free, 4 on Dev, 5 on Team. - Size: downscaled by the host, 540 px wide by default; scale input coordinates back to device pixels. - On connect you get the most recent frame straight away if there is one. An unchanged screen is resent every 10 seconds. ### Events **Text messages** are JSON objects with a `t` field: | `t` | Fields | When | |---|---|---| | `status` | `device` (the [device object](https://akaphone-docs.pages.dev/api/devices/#the-device-object)) | On connect, and whenever the device's status changes. | | `task.started` | `task_id` | ARTEMIS started a task. | | `task.step` | `task_id`, `n`, `label`, `ms` | Each step of a running task. | | `task.done` | `task` (the [task object](https://akaphone-docs.pages.dev/api/tasks/#the-task-object), without `steps`) | A task finished: succeeded, failed, cancelled or timed out. | | `app.installed` | `device_id`, `install_id`, `package`, `error` | An [APK install](https://akaphone-docs.pages.dev/api/devices/#install-an-app) finished (also available from [get an install](https://akaphone-docs.pages.dev/api/devices/#get-an-install)). `error` is `null` on success; `package` may be `null` if it could not be determined. | | `credits.low` | `balance` | Your balance is 5 credits or less. | | `device.stopped` | `reason`: `user`, `idle`, `credits` or `lost` | The device is stopping or has stopped. No more frames will come; close the socket. | | `error` | `error: { code, message }` | Your last input was rejected: `rate_limited` or `invalid_request`. | ```json {"t":"status","device":{"id":"dev_s6zr7mr4f2f1","status":"running","…":"…"}} {"t":"task.started","task_id":"tsk_01m5vqsywgehe4jhva3g0r"} {"t":"task.step","task_id":"tsk_01m5vqsywgehe4jhva3g0r","n":1,"label":"Open the Settings app","ms":2140} {"t":"task.done","task":{"id":"tsk_01m5vqsywgehe4jhva3g0r","status":"succeeded","result":"Android version: 14","…":"…"}} {"t":"app.installed","device_id":"dev_s6zr7mr4f2f1","install_id":"ins_01m5vqsywg99189k5xj7f5","package":"com.example.shop","error":null} {"t":"credits.low","balance":4} {"t":"device.stopped","reason":"credits"} ``` Ignore event types you do not recognise; new ones may be added. ## Client to server ### Sending input Send input as a text message with `t: "input"` and the same fields as [`POST /v1/devices/:id/input`](https://akaphone-docs.pages.dev/api/devices/#send-input): ```json {"t":"input","type":"tap","x":360,"y":780} {"t":"input","type":"swipe","x1":360,"y1":1200,"x2":360,"y2":400,"ms":250} {"t":"input","type":"text","text":"hello@example.com"} {"t":"input","type":"key","key":"BACK"} ``` - Coordinates are device pixels (720×1560 for `pixel_8`), not frame pixels. - `text` is at most 500 characters per message. Keys: `HOME`, `BACK`, `RECENTS`, `ENTER`, `DEL`, `TAB`. - At most **20 messages per second per connection**; extra messages are dropped and you get an `error` event. - Input is ignored while the device is not `running`. Any other message type is ignored. ## Keeping the device alive An open connection counts as activity: a device with a viewer is never stopped as idle. It still stops when you stop it or your credits run out. Close sockets you are not using. ## Examples ```typescript title="Browser" // 1. Your server: POST /v1/devices/:id/stream-token with your API key, return { token } to the page. const { token } = await fetch(`/my-backend/stream-token?device=${deviceId}`).then((r) => r.json()); // 2. The page: connect with the token, draw frames on a canvas. const ws = new WebSocket(`wss://akaphone.abhgnx-417.workers.dev/v1/devices/${deviceId}/stream?key=${encodeURIComponent(token)}`); ws.binaryType = "arraybuffer"; const canvas = document.querySelector("canvas")!; const ctx = canvas.getContext("2d")!; ws.onmessage = async (msg) => { if (typeof msg.data !== "string") { const img = await createImageBitmap(new Blob([msg.data], { type: "image/jpeg" })); canvas.width = img.width; canvas.height = img.height; ctx.drawImage(img, 0, 0); img.close(); return; } const ev = JSON.parse(msg.data); if (ev.t === "task.step") console.log(`step ${ev.n}: ${ev.label}`); if (ev.t === "device.stopped") ws.close(); }; // Tap where the user clicked, scaled from frame pixels to device pixels (720 wide for pixel_8). canvas.addEventListener("click", (e) => { const r = canvas.getBoundingClientRect(); const scale = 720 / r.width; ws.send(JSON.stringify({ t: "input", type: "tap", x: Math.round((e.clientX - r.left) * scale), y: Math.round((e.clientY - r.top) * scale) })); }); ``` ```typescript title="Node.js" // npm install ws import WebSocket from "ws"; import { writeFileSync } from "node:fs"; const ws = new WebSocket("wss://akaphone.abhgnx-417.workers.dev/v1/devices/dev_s6zr7mr4f2f1/stream", { headers: { Authorization: `Bearer ${process.env.AKAPHONE_API_KEY}` }, }); let frames = 0; ws.on("message", (data, isBinary) => { if (isBinary) { writeFileSync(`frame-${String(frames++).padStart(4, "0")}.jpg`, data as Buffer); return; } const ev = JSON.parse(data.toString()); console.log(ev.t, ev); if (ev.t === "device.stopped") ws.close(); }); ws.on("open", () => ws.send(JSON.stringify({ t: "input", type: "key", key: "HOME" }))); ``` ```python title="Python" # pip install "websockets>=14" import asyncio, json, os import websockets async def main(): url = "wss://akaphone.abhgnx-417.workers.dev/v1/devices/dev_s6zr7mr4f2f1/stream" headers = {"Authorization": f"Bearer {os.environ['AKAPHONE_API_KEY']}"} async with websockets.connect(url, additional_headers=headers, max_size=None) as ws: await ws.send(json.dumps({"t": "input", "type": "key", "key": "HOME"})) async for msg in ws: if isinstance(msg, bytes): open("latest.jpg", "wb").write(msg) continue ev = json.loads(msg) print(ev["t"], ev) if ev["t"] == "device.stopped": break asyncio.run(main()) ``` ## Reconnecting Connections can drop (network changes, deploys). Reconnect with exponential backoff, and get a **new** stream token for each attempt if you use tokens. On reconnect you receive a fresh `status` event and the latest frame, so there is no state to replay. Stop reconnecting after `device.stopped`. --- # API keys API > List, create and revoke ak_live_ API keys. Secrets are shown once and stored only as a SHA-256 hash. Source: https://akaphone-docs.pages.dev/api/keys/ API keys authenticate the MCP server, scripts and CI. You can manage them in the dashboard under **API keys**, or with these endpoints. A key has the full rights of your account, including managing other keys, so treat it like a password. ## The key object ```json { "id": "key_01m5vqsywgc25cftvdhxzj", "name": "github-actions", "prefix": "ak_live_urQu", "last_used_at": "2026-10-26T09:14:02.000Z", "created_at": "2026-10-20T15:44:31.000Z" } ``` | Field | Type | Description | |---|---|---| | `id` | string | `key_…`. Use it to revoke the key. | | `name` | string or null | Your label. | | `prefix` | string | The first 12 characters of the secret, to recognise it. | | `last_used_at` | string or null | Last use, updated at most once a minute. | | `created_at` | string | When it was created. | ## List keys `GET /v1/keys` Returns your active (not revoked) keys, newest first. ```bash title="curl" curl https://akaphone.abhgnx-417.workers.dev/v1/keys \ -H "Authorization: Bearer $AKAPHONE_API_KEY" ``` ```typescript title="TypeScript" const { keys } = await fetch("https://akaphone.abhgnx-417.workers.dev/v1/keys", { headers: { Authorization: `Bearer ${process.env.AKAPHONE_API_KEY}` }, }).then((r) => r.json()); ``` ```python title="Python" import os, requests keys = requests.get( "https://akaphone.abhgnx-417.workers.dev/v1/keys", headers={"Authorization": f"Bearer {os.environ['AKAPHONE_API_KEY']}"}, ).json()["keys"] ``` **Response** `200 OK` ```json { "keys": [ { "id": "key_01m5vqsywgc25cftvdhxzj", "name": "github-actions", "prefix": "ak_live_urQu", "last_used_at": "2026-10-26T09:14:02.000Z", "created_at": "2026-10-20T15:44:31.000Z" } ] } ``` ## Create a key `POST /v1/keys` | Body field | Type | Description | |---|---|---| | `name` | string | Optional, up to 64 characters. | ```bash title="curl" curl -X POST https://akaphone.abhgnx-417.workers.dev/v1/keys \ -H "Authorization: Bearer $AKAPHONE_API_KEY" \ -H "Content-Type: application/json" \ -d '{"name": "github-actions"}' ``` ```typescript title="TypeScript" const { key, secret } = await fetch("https://akaphone.abhgnx-417.workers.dev/v1/keys", { method: "POST", headers: { Authorization: `Bearer ${process.env.AKAPHONE_API_KEY}`, "Content-Type": "application/json" }, body: JSON.stringify({ name: "github-actions" }), }).then((r) => r.json()); ``` ```python title="Python" created = requests.post( "https://akaphone.abhgnx-417.workers.dev/v1/keys", headers={"Authorization": f"Bearer {os.environ['AKAPHONE_API_KEY']}"}, json={"name": "github-actions"}, ).json() secret = created["secret"] ``` **Response** `201 Created` ```json { "key": { "id": "key_01m5vqsywgc25cftvdhxzj", "name": "github-actions", "prefix": "ak_live_urQu", "last_used_at": null, "created_at": "2026-10-26T09:30:00.000Z" }, "secret": "ak_live_urQuj41lKvo2VYp2kBoFqRGw5ig661RoAtM5uTvyrhB" } ``` `secret` is returned **only in this response**. AkaPhone keeps a SHA-256 hash, so it cannot show the secret again. | Error | When | |---|---| | `409 conflict` | You already have 25 active keys (*At most 25 active API keys*). Revoke one first. | ## Revoke a key `DELETE /v1/keys/:id` Revokes the key immediately. Requests using it get `401 unauthorized` from then on. You can revoke the key you are calling with. ```bash title="curl" curl -X DELETE https://akaphone.abhgnx-417.workers.dev/v1/keys/key_01m5vqsywgc25cftvdhxzj \ -H "Authorization: Bearer $AKAPHONE_API_KEY" ``` ```typescript title="TypeScript" await fetch("https://akaphone.abhgnx-417.workers.dev/v1/keys/key_01m5vqsywgc25cftvdhxzj", { method: "DELETE", headers: { Authorization: `Bearer ${process.env.AKAPHONE_API_KEY}` }, }); ``` ```python title="Python" requests.delete( "https://akaphone.abhgnx-417.workers.dev/v1/keys/key_01m5vqsywgc25cftvdhxzj", headers={"Authorization": f"Bearer {os.environ['AKAPHONE_API_KEY']}"}, ) ``` **Response** `204 No Content`. `404 not_found` if the key does not exist or is already revoked. ## Good practice - One key per machine, pipeline or teammate, named after it. Revoking one does not disturb the others. - Store keys in a secrets manager or your CI's secret store, never in source code or front-end bundles. - If a key leaks, revoke it first and create the replacement second. --- # Usage API > Device-minutes used over a time range, per day and per device, exact up to the current minute. Source: https://akaphone-docs.pages.dev/api/usage/ ## Get usage `GET /v1/usage` Returns the device-minutes charged in a time range, in total, per UTC day and per device. Minutes still in the current 10-minute window of a running device are included, so the numbers are exact at the moment you ask. | Query | Type | Default | Description | |---|---|---|---| | `from` | ISO 8601 time or Unix milliseconds | Start of the current month (UTC) | Inclusive. | | `to` | ISO 8601 time or Unix milliseconds | Now | Exclusive. | ```bash title="curl" curl "https://akaphone.abhgnx-417.workers.dev/v1/usage?from=2026-10-01T00:00:00Z&to=2026-11-01T00:00:00Z" \ -H "Authorization: Bearer $AKAPHONE_API_KEY" ``` ```typescript title="TypeScript" const params = new URLSearchParams({ from: "2026-10-01T00:00:00Z", to: "2026-11-01T00:00:00Z" }); const usage = await fetch(`https://akaphone.abhgnx-417.workers.dev/v1/usage?${params}`, { headers: { Authorization: `Bearer ${process.env.AKAPHONE_API_KEY}` }, }).then((r) => r.json()); console.log(`${usage.total_minutes} minutes this month`); ``` ```python title="Python" import os, requests usage = requests.get( "https://akaphone.abhgnx-417.workers.dev/v1/usage", params={"from": "2026-10-01T00:00:00Z", "to": "2026-11-01T00:00:00Z"}, headers={"Authorization": f"Bearer {os.environ['AKAPHONE_API_KEY']}"}, ).json() ``` **Response** `200 OK` ```json { "from": "2026-10-01T00:00:00.000Z", "to": "2026-10-26T09:40:12.000Z", "total_minutes": 143, "active_devices": 1, "by_day": [ { "date": "2026-10-20", "minutes": 41 }, { "date": "2026-10-25", "minutes": 74 }, { "date": "2026-10-26", "minutes": 28 } ], "devices": [ { "device_id": "dev_ynxe87v87zx0", "minutes": 74, "status": "terminated", "name": null }, { "device_id": "dev_qj196bgdqs10", "minutes": 41, "status": "terminated", "name": "run-1842" }, { "device_id": "dev_s6zr7mr4f2f1", "minutes": 28, "status": "running", "name": "checkout-test" } ] } ``` | Field | Description | |---|---| | `from`, `to` | The range used. `to` is capped at the current time. | | `total_minutes` | Sum of `by_day`. | | `active_devices` | Devices in `devices` that are running now. | | `by_day` | Minutes per UTC day, oldest first. Days with no usage are omitted. | | `devices` | Minutes per device in the range, most first, with its current `status` and `name`. | `400 invalid_request` if `from` or `to` cannot be parsed. > **Usage is recorded in windows** > > Minutes are charged one at a time but written to the ledger once per device per 10-minute window, dated at the end of the window. A device that ran across midnight UTC may have its minutes counted on the later day. Usage older than 90 days is combined into one row per month; for those months `devices` is empty and `by_day` shows the month's total on its first day. Your balance and the minutes used this calendar month are also in [`GET /v1/me`](https://akaphone-docs.pages.dev/api/me/). --- # Billing API > Read the credit ledger and create Lemon Squeezy checkout links for credit packs. Source: https://akaphone-docs.pages.dev/api/billing/ See the [Billing guide](https://akaphone-docs.pages.dev/guides/billing/) for packs, plans, receipts and refunds. ## List ledger entries `GET /v1/billing/ledger` Every credit added to or taken from your balance, newest first. | Query | Type | Default | Description | |---|---|---|---| | `limit` | integer | 50 | 1–500. | ```bash title="curl" curl "https://akaphone.abhgnx-417.workers.dev/v1/billing/ledger?limit=20" \ -H "Authorization: Bearer $AKAPHONE_API_KEY" ``` ```typescript title="TypeScript" const { entries } = await fetch("https://akaphone.abhgnx-417.workers.dev/v1/billing/ledger?limit=20", { headers: { Authorization: `Bearer ${process.env.AKAPHONE_API_KEY}` }, }).then((r) => r.json()); ``` ```python title="Python" import os, requests entries = requests.get( "https://akaphone.abhgnx-417.workers.dev/v1/billing/ledger", params={"limit": 20}, headers={"Authorization": f"Bearer {os.environ['AKAPHONE_API_KEY']}"}, ).json()["entries"] ``` **Response** `200 OK` ```json { "entries": [ { "id": "led_01m5vqsywgmvjvevzqnbkz", "delta": -10, "kind": "usage", "ref": "dev_s6zr7mr4f2f1:w1", "created_at": "2026-10-26T09:24:09.000Z" }, { "id": "led_01m5vqsywgmzjsatq42611", "delta": 1375, "kind": "purchase", "ref": "4821733", "created_at": "2026-10-26T09:02:51.000Z" }, { "id": "led_01m5vqsywgadfgngs4y1q2", "delta": 60, "kind": "grant", "ref": "signup:usr_01m5vqsywg3mxj7dyxv9q9", "created_at": "2026-10-20T15:40:12.000Z" } ] } ``` | Field | Description | |---|---| | `id` | `led_…` | | `delta` | Credits added (positive) or removed (negative). | | `kind` | `grant`, `purchase`, `usage`, `refund` or `adjust`. | | `ref` | What it refers to: `signup:` for the signup grant, the Lemon Squeezy order id for purchases, `:w` for the *n*th 10-minute usage window of a device, `:YYYY-MM` for usage combined by month after 90 days, `:r` for refunds. | | `created_at` | When it was recorded. | ## Create a checkout `POST /v1/billing/checkout` Creates a Lemon Squeezy hosted checkout for a credit pack and returns its URL. Open the URL in a browser to pay. Credits are added when Lemon Squeezy confirms the payment, and the buyer is sent back to `https://akaphone.abhgnx-417.workers.dev/billing?checkout=success`. | Body field | Type | Description | |---|---|---| | `pack` | string | `"250"` ($10), `"1375"` ($50), `"6000"` ($200) or `"team"` ($199, 6,000 credits and the Team plan for 30 days). | ```bash title="curl" curl -X POST https://akaphone.abhgnx-417.workers.dev/v1/billing/checkout \ -H "Authorization: Bearer $AKAPHONE_API_KEY" \ -H "Content-Type: application/json" \ -d '{"pack": "250"}' ``` ```typescript title="TypeScript" const { url } = await fetch("https://akaphone.abhgnx-417.workers.dev/v1/billing/checkout", { method: "POST", headers: { Authorization: `Bearer ${process.env.AKAPHONE_API_KEY}`, "Content-Type": "application/json" }, body: JSON.stringify({ pack: "250" }), }).then((r) => r.json()); ``` ```python title="Python" url = requests.post( "https://akaphone.abhgnx-417.workers.dev/v1/billing/checkout", headers={"Authorization": f"Bearer {os.environ['AKAPHONE_API_KEY']}"}, json={"pack": "250"}, ).json()["url"] ``` **Response** `200 OK` ```json { "url": "https://akaphone.lemonsqueezy.com/checkout/custom/4f1c0e7a-…" } ``` | Error | When | |---|---| | `400 invalid_request` | `pack` is not one of the four ids. Note that pack ids are strings: `"250"`, not `250`. | | `500 internal` | The checkout could not be created at Lemon Squeezy. Retry later. | To check that a purchase went through, look for a `purchase` entry in the ledger or a higher `credits.balance` in [`GET /v1/me`](https://akaphone-docs.pages.dev/api/me/). --- # Me API > The signed-in account - email, plan, plan expiry, concurrency limit, credit balance and minutes used this month. Source: https://akaphone-docs.pages.dev/api/me/ ## Get the current account `GET /v1/me` Returns who the key or session belongs to, the plan in force right now, and the exact credit balance. A cheap way to check that a key works. ```bash title="curl" curl https://akaphone.abhgnx-417.workers.dev/v1/me \ -H "Authorization: Bearer $AKAPHONE_API_KEY" ``` ```typescript title="TypeScript" const me = await fetch("https://akaphone.abhgnx-417.workers.dev/v1/me", { headers: { Authorization: `Bearer ${process.env.AKAPHONE_API_KEY}` }, }).then((r) => r.json()); console.log(`${me.user.email}: ${me.credits.balance} credits on ${me.user.plan}`); ``` ```python title="Python" import os, requests me = requests.get( "https://akaphone.abhgnx-417.workers.dev/v1/me", headers={"Authorization": f"Bearer {os.environ['AKAPHONE_API_KEY']}"}, ).json() print(me["user"]["email"], me["credits"]["balance"]) ``` **Response** `200 OK` ```json { "user": { "id": "usr_01m5vqsywg3mxj7dyxv9q9", "email": "ada@example.com", "name": "Ada Lovelace", "avatar_url": "https://lh3.googleusercontent.com/a/ACg8ocK…", "plan": "dev", "plan_expires_at": null, "max_concurrent": 3 }, "credits": { "balance": 1292, "used_this_month": 143 } } ``` | Field | Description | |---|---| | `user.id` | `usr_…` | | `user.email`, `user.name`, `user.avatar_url` | From your Google account. | | `user.plan` | The plan in force now: `free`, `dev`, `team` or `enterprise`. An expired Team plan already shows as `dev`. | | `user.plan_expires_at` | When the Team plan ends, otherwise `null`. | | `user.max_concurrent` | How many devices you can have queued, creating or running at once. | | `credits.balance` | Credits available now. Can be negative after a refund. | | `credits.used_this_month` | Device-minutes charged since the start of the calendar month (UTC). | `401 unauthorized` if the key is invalid or revoked; `403 forbidden` if the account is suspended. ## Sign in and out (dashboard only) The dashboard uses Google sign-in rather than API keys. These endpoints are browser navigations, not API calls, and are listed for completeness: | Endpoint | What it does | |---|---| | `GET /auth/google/start?redirect=/` | Redirects to Google. After sign-in you return to `https://akaphone.abhgnx-417.workers.dev` plus `redirect` (a path on the dashboard). | | `POST /auth/logout` | Ends the session and clears the cookie. Returns `204`. | Revoking API keys does not sign you out of the dashboard, and signing out does not revoke keys. --- # MCP tools reference > Every tool the akaphone-mcp server exposes to Claude Code, Cursor and Codex, with arguments, what each returns, the REST call behind it and example prompts. Source: https://akaphone-docs.pages.dev/mcp/tools/ `akaphone-mcp` 1.0.0 exposes 13 tools. Your agent picks them from your request. You rarely name them yourself, but knowing what they do helps you ask for the right thing. Setup is in [Claude Code](https://akaphone-docs.pages.dev/quickstart/claude-code/) and [Cursor and Codex](https://akaphone-docs.pages.dev/quickstart/cursor-and-codex/). Most tools return JSON text (the API response, pretty-printed); the exceptions are noted. If the API returns an error, the tool result is marked as an error and reads `AkaPhone API : `. For `429` responses it also says how many seconds to wait. ## Summary | Tool | What it does | REST calls | |---|---|---| | [`device_create`](#device-create) | Boot a new Android 14 device | `POST /v1/devices` | | [`device_list`](#device-list) | List your devices | `GET /v1/devices` | | [`device_terminate`](#device-terminate) | Stop a device and its billing | `DELETE /v1/devices/:id` | | [`device_screenshot`](#device-screenshot) | The current screen as an image | `GET /v1/devices/:id/screenshot` | | [`device_input`](#device-input) | Tap, swipe, type or press a key | `POST /v1/devices/:id/input` | | [`device_install_apk`](#device-install-apk) | Upload a local APK and install it | `POST /v1/uploads`, `PUT` to storage, `POST /v1/uploads/:id/complete`, `POST /v1/devices/:id/apps`, `GET /v1/devices/:id/apps/:install_id` | | [`device_snapshot`](#device-snapshot) | Save a device's disk as a snapshot | `POST /v1/devices/:id/snapshot` | | [`device_restore`](#device-restore) | Boot a new device from a snapshot, or list snapshots | `POST /v1/snapshots/:id/restore`, `GET /v1/snapshots` | | [`device_run_task`](#device-run-task) | Run a plain-English task and wait for the result | `POST /v1/devices/:id/tasks`, then `GET /v1/tasks/:id` | | [`task_status`](#task-status) | Get a task | `GET /v1/tasks/:id` | | [`task_artifacts`](#task-artifacts) | List or fetch a task's video, screenshots, trace and log | `GET /v1/tasks/:id/artifacts` | | [`usage`](#usage) | Device-minutes this month | `GET /v1/usage` | | [`profiles_list`](#profiles-list) | Device profiles for `device_create` | `GET /v1/profiles` | ## device_create Boots a fresh Android 14 phone and, by default, waits until it is running. | Argument | Type | Default | Description | |---|---|---|---| | `name` | string | none | Friendly name, up to 64 characters. | | `profile` | string | `pixel_8` | `pixel_8`, `pixel_tablet` or `small_phone` (see `profiles_list`). | | `wait` | boolean | `true` | Wait until the device is running, for at most 90 seconds. | **Returns** the [device object](https://akaphone-docs.pages.dev/api/devices/#the-device-object). If the 90 seconds run out while capacity is busy, the API sends `X-Wait-Timed-Out: 1` and the tool adds a note: the device is still `queued` or `creating`, and the agent should check it again with `device_list`. > Create a phone called "signup-test". ## device_list | Argument | Type | Default | Description | |---|---|---|---| | `include_terminated` | boolean | `false` | Also list finished devices (the 200 most recent). | **Returns** an array of devices. Without `include_terminated`, only `queued`, `creating`, `running` and `stopping` devices. > Which phones do I have running? ## device_terminate | Argument | Type | Description | |---|---|---| | `device_id` | string | The device to stop. | **Returns** the device, now `stopping` (or `terminated` if it was queued). Billing stops. > Terminate all my running phones. ## device_screenshot | Argument | Type | Description | |---|---|---| | `device_id` | string | A running device. | **Returns** an `image/jpeg` image of the current screen, which the agent can look at directly. > Show me what's on the screen of signup-test. ## device_input Sends one input event to a running device. Coordinates are device pixels: 720×1560 on `pixel_8` (`profiles_list` has the others). | Argument | Type | Used with | Description | |---|---|---|---| | `device_id` | string | all | A running device. | | `type` | `tap`, `swipe`, `text` or `key` | all | The kind of event. | | `x`, `y` | integer, 0–10000 | `tap` | Where to tap. | | `x1`, `y1`, `x2`, `y2` | integer, 0–10000 | `swipe` | Start and end points. | | `ms` | integer, 1–10000 | `swipe` | Duration, default 300. | | `text` | string, 1–500 characters | `text` | Text to type into the focused field. | | `key` | `HOME`, `BACK`, `RECENTS`, `ENTER`, `DEL` or `TAB` | `key` | Hardware or navigation key. | **Returns** a short confirmation. A missing field for the chosen type (for example a `tap` without `y`) is an error before anything is sent. Input does not confirm that the tap had an effect; follow it with `device_screenshot`. > On signup-test, tap the email field at 360, 640, type test@example.com and press Enter. ## device_install_apk Uploads a local APK and installs it on a running device, then waits for the result. | Argument | Type | Description | |---|---|---| | `device_id` | string | A running device. | | `file_path` | string | Path to a local `.apk` file, at most 300 MB. | The tool creates an upload, `PUT`s the file straight to storage with the presigned URL, completes the upload, starts the install, and polls the install status for up to **5 minutes**. **Returns** the install: `status` `installed` with the `package` name, or an error result with `status` `failed` and the installer's `error`. If the install is still pending after 5 minutes, the tool says so; the install continues on the device. Runtime permissions are granted on install. `.aab` bundles are refused before any upload; build a universal APK first. See [Install an APK](https://akaphone-docs.pages.dev/guides/install-apk/). > Install ./app/build/outputs/apk/debug/app-debug.apk on signup-test and open it. ## device_snapshot | Argument | Type | Description | |---|---|---| | `device_id` | string | A running device. | | `name` | string | Optional label, up to 64 characters. | **Returns** the [snapshot](https://akaphone-docs.pages.dev/api/snapshots/#the-snapshot-object) in `pending` status. It becomes `ready` after a short while. Your plan's snapshot quota applies, and you can take at most 5 snapshots per hour. See [Snapshots](https://akaphone-docs.pages.dev/concepts/snapshots/). > Snapshot signup-test as "logged-in". ## device_restore | Argument | Type | Default | Description | |---|---|---|---| | `snapshot_id` | string | none | Snapshot to boot from. **Omit it to list your snapshots instead.** | | `name` | string | none | Name for the new device. | | `wait` | boolean | `true` | Wait until the new device is running, for at most 90 seconds. | **Returns** the new device (with the same timed-out note as `device_create`), or the snapshot list when `snapshot_id` is omitted. The original device is not touched. > Start a new phone from the "logged-in" snapshot. ## device_run_task Has ARTEMIS carry out a plain-English instruction on a device. It then polls every 3 seconds until the task finishes or `wait_seconds` runs out. | Argument | Type | Default | Description | |---|---|---|---| | `device_id` | string | required | A running device with no active task. | | `instruction` | string | required | What to do, 1–8,000 characters. | | `profile` | `"flash"` or `"pro"` | `"pro"` | `flash` for quick, simple actions; `pro` for multi-step work. The REST API's own default is `flash`. | | `wait_seconds` | integer | `600` | 0–1800. `0` returns at once with the queued task. | The tool sends `MODEL_API_KEY` and `MODEL_PROVIDER` from the server's environment as the `X-Model-Key` and `X-Model-Provider` headers. The host picks the model for the provider and profile ([Model keys](https://akaphone-docs.pages.dev/guides/model-keys/#models)). To get structured output back, describe it in the instruction, for example *“reply with JSON {version, build}”*. **Returns** the [task](https://akaphone-docs.pages.dev/api/tasks/#the-task-object) with `steps`, `status` and `result`. If the task is still running when `wait_seconds` runs out, the text ends with *(still running; poll with task_status)*. > On signup-test, open the app, sign up as test+42@example.com with password Test1234!, and tell me what the first screen after sign-up says. ## task_status | Argument | Type | Description | |---|---|---| | `task_id` | string | The task. | **Returns** the task with its `steps`. Statuses: `queued`, `running`, `succeeded`, `failed`, `cancelled`. > Is that task finished yet? ## task_artifacts | Argument | Type | Description | |---|---|---| | `task_id` | string | A finished task. | | `download` | string | Optional. The `id` (`art_…`) or file name of one artifact to fetch. | **Without `download`**, the tool returns `{ "files": [...] }`. Each file has its `id`, `kind`, `name`, `size_bytes` and a presigned download `url`. The URLs are valid for 15 minutes. The files are `video.mp4`, `before.png`, `after.png`, `trace.tar.gz` and `stdout.log`. The list is empty for a few seconds after a task ends, while the host uploads the files. **With `download`**, the tool fetches that file through its presigned URL: | File | Returned as | |---|---| | `before.png`, `after.png` (screenshots) | an image the agent can see | | `stdout.log` | text (the last 200 KB if it is longer) | | `video.mp4`, `trace.tar.gz` | a note with the download URL, since binary files cannot be shown inline | > Show me the after screenshot of the last task, and the end of its log. ## usage No arguments. **Returns** the [usage report](https://akaphone-docs.pages.dev/api/usage/) for the current month: `total_minutes`, `active_devices`, minutes per day and per device. > How many device-minutes have I used this month? ## profiles_list No arguments. **Returns** the device profiles with `id`, `name`, `width`, `height`, `dpi` and `android`: ```json [ { "id": "pixel_8", "name": "Pixel 8", "width": 720, "height": 1560, "dpi": 320, "android": "14" }, { "id": "pixel_tablet", "name": "Pixel Tablet", "width": 1280, "height": 800, "dpi": 240, "android": "14" }, { "id": "small_phone", "name": "Small phone", "width": 540, "height": 960, "dpi": 240, "android": "14" } ] ``` > Which device sizes can I use? Start a tablet. ## Environment variables | Variable | Required | Description | |---|---|---| | `AKAPHONE_API_KEY` | yes | Your `ak_live_…` key. The server exits at startup without it. | | `AKAPHONE_API_URL` | no | Defaults to `https://akaphone.abhgnx-417.workers.dev`. | | `MODEL_API_KEY` | for tasks | Your Gemini, Anthropic or OpenAI key. | | `MODEL_PROVIDER` | no | `gemini` (default), `anthropic` or `openai`. Any other value stops the server at startup with `MODEL_PROVIDER must be one of gemini, anthropic, openai`. | ## Tips for prompts - **Name your devices** and refer to them by name. The agent maps names to ids with `device_list`. - **Ask for a verdict.** Ending with *“…and tell me PASS or FAIL”* makes results easy to scan. - **Ask it to clean up.** End with *“then terminate the phone”* unless you want to keep it. - **Keep secrets out of instructions.** Instructions are stored with the task, so use test accounts. --- # Security > How AkaPhone handles sign-in, API keys, model keys and device isolation, what is shared between accounts on a host, how long data is kept, and how to report a vulnerability. Source: https://akaphone-docs.pages.dev/security/ This page describes how the hosted service works today, including its limits. If something here does not match what you see, tell us at [security@akaphone.com](mailto:security@akaphone.com). ## Sign-in and sessions - **Google is the only way to sign in.** There are no AkaPhone passwords to leak or reset. The OAuth flow uses PKCE (S256), a single-use `state` bound to your browser, and a `nonce`; the ID token's signature, issuer, audience, expiry and `email_verified` claim are all checked. - **Accounts are tied to a Google account**, not just an email address. A different Google account presenting the same email is refused rather than merged. - **Sessions** are a random id in an `HttpOnly`, `Secure`, `SameSite=Lax` cookie set only on the API host, signed with HMAC so forged cookies are rejected outright. Sessions last 30 days; signing out deletes them. - **Cross-site requests are refused.** Requests authenticated by the cookie that change anything must come from the dashboard's origin, and so must live-stream connections. Other sites get no CORS access. ## API keys - Keys are 43 random characters after `ak_live_`. Only a **SHA-256 hash** is stored, so a database leak does not reveal usable keys, and AkaPhone cannot show you a key again. - Keys can be revoked at any time and stop working immediately. `last_used_at` helps spot keys that are unused or used unexpectedly. - Keys have no scopes: each has the full rights of your account. Use one per machine or pipeline. ## Model keys Your Gemini, Anthropic or OpenAI key is sent per task in the `X-Model-Key` header and is **never stored**: - The API holds it in memory for the request and passes it to the device's host in the task message. It is not written to the database, KV storage, logs or the audit trail, and is never returned. Automated tests check the database, audit log and KV for it. - On the host it lives only in the memory of the agent and in the environment of that task's ARTEMIS process: not on disk, not on a command line, not in logs. Logs, the task log artifact and the task result go through a redactor that strips it. - The key never reaches the Android system on the device. The dashboard keeps the key you paste in your browser's local storage on that computer, so you do not have to paste it every time. **Forget key** removes it. See [Model keys](https://akaphone-docs.pages.dev/guides/model-keys/). ## Device isolation Each device is its own Android container on a AkaPhone host: - **Own network.** Every device has a separate Docker network with traffic between containers disabled. - **Egress filtering.** Devices cannot send email (SMTP ports are blocked), reach the cloud provider's metadata service, or reach private network ranges. Other outbound internet traffic is allowed, so apps work normally. - **No inbound access.** Hosts have no open inbound ports. They connect out to AkaPhone's control plane. The Android debug bridge listens only on the host's loopback interface. - **Nothing persists.** When a device stops, its container and disk are deleted. Only [snapshots](https://akaphone-docs.pages.dev/concepts/snapshots/) you take survive. > **Devices from different accounts can share a host** > > To run Android, device containers need elevated (privileged) access to the host kernel. Devices from different accounts can run side by side on the same host. If an attacker fully compromised one device, for example through a malicious APK combined with an Android or kernel privilege-escalation bug, they could realistically break out of the container to the host, and from there see the screens, app data and anything typed into other devices on that host. > > We treat that as a real risk, not a theoretical one. Until per-account host isolation is available on the hosted service (see the [roadmap](https://akaphone-docs.pages.dev/roadmap/)): > > - **Use test accounts and test data** on devices. Do not sign in to production admin accounts, personal email or banking. > - **Do not type secrets into a device** that you would not want another tenant to see. Your AkaPhone API key and your model key never enter the device, so they are not exposed this way. > - **Install only APKs you trust.** ## Data and retention | Data | Where | Kept for | |---|---|---| | Account (Google id, email, name, avatar URL) | Cloudflare D1 | While your account exists | | API key hashes | Cloudflare D1 | Until revoked | | Device records | Cloudflare D1 | Kept for your history | | Task records and steps | Cloudflare D1 | Your plan's retention: 7, 30 or 90 days | | Task artifacts (video, screenshots, trace, log) | Cloudflare R2 | Your plan's retention: 7, 30 or 90 days | | Uploaded APKs | Cloudflare R2 | 7 days | | Snapshots | The device host's disk | Until you delete them or the host is retired | | Credit ledger | Cloudflare D1 | Kept; usage rows are combined by month after 90 days | | Audit log (sign-ins, key and device actions) | Cloudflare D1 | 180 days | | Model keys | Memory only | Never stored | Device hosts run in Hetzner data centres in Germany (region `eu-central`). The API, database and file storage run on Cloudflare. Payments are handled by Lemon Squeezy as merchant of record; AkaPhone never sees your card details. Database backups are taken nightly. Artifacts and uploads are reachable only through short-lived presigned URLs (15 minutes) issued to your account. ## Transport All endpoints are HTTPS-only with HSTS. API responses carry a restrictive `Content-Security-Policy`, `X-Frame-Options: DENY`, `X-Content-Type-Options: nosniff` and `Referrer-Policy: no-referrer`. ## Acceptable use Do not use AkaPhone to automate accounts you do not own, create accounts in bulk, scrape, commit ad or review fraud, bypass rate limits or CAPTCHAs, or break another service's terms. Accounts that do are suspended: every API key and session stops working and running devices are stopped. ## Reporting a vulnerability Email **[security@akaphone.com](mailto:security@akaphone.com)** with what you found, how to reproduce it, and its impact as you understand it. Please: - give us a reasonable time to fix it before disclosing it publicly, - test only against your own account and devices, and do not access other users' data, - avoid denial-of-service testing and anything that degrades the service for others. We read every report and will keep you updated while we work on a fix. --- # FAQ > Short answers about pricing, model keys, the Play Store, speed, CI, snapshots, limits and what AkaPhone is built on. Source: https://akaphone-docs.pages.dev/faq/ ## What does it cost? One credit per device-minute while a device is running, with 60 free minutes when you sign up. Packs start at $10 for 250 minutes, so a five-minute test costs about 20 cents. Tasks, streaming, installs, snapshots and API calls are free; only running device time is billed. See [Credits and plans](https://akaphone-docs.pages.dev/concepts/credits-and-plans/). ## Do I need a subscription? No. Credits are prepaid and nothing renews automatically. The Team pack sets the Team plan for 30 days; buy it again to extend. ## Do I bring my own model key? Yes, for tasks. Send a Gemini, Anthropic or OpenAI key with each task. It is used for that task and never stored. Your provider bills the tokens; AkaPhone bills device time. You can use devices without a model key for anything that is not a task: live view, input, screenshots and installs. See [Model keys](https://akaphone-docs.pages.dev/guides/model-keys/). ## Is this just ARTEMIS? The agent that runs tasks is Google's open-source [ARTEMIS](https://github.com/google/artemis), unmodified. AkaPhone runs the fleet of Android devices around it: creating and stopping them, streaming the screen, installs, snapshots, artifacts, the MCP server, the dashboard, the API and billing. ## Which Android versions and devices? Android 14, with three profiles: `pixel_8` (720×1560, the default), `pixel_tablet` (1280×800) and `small_phone` (540×960). Devices are virtual Android systems in containers, not physical phones. Physical devices are on the [roadmap](https://akaphone-docs.pages.dev/roadmap/). ## Can a device use the Play Store? No. Devices are Android Open Source Project builds without Google Play Services. Apps that need the Play Store, Play Services or Play Integrity will not work fully. Install your app as an APK instead. ## How fast is it? A device boots in seconds when a warm slot is free. When every slot is busy it queues while more capacity starts, typically a minute or two; queued time is not billed. ARTEMIS takes a few seconds per step depending on the model, so a 15-step flow usually takes two to four minutes. ## Can I run it in CI? Yes, with the REST API and `curl`. [Testing in CI](https://akaphone-docs.pages.dev/guides/testing-in-ci/) has a complete GitHub Actions workflow that installs your APK, runs a task, fails the build on a failed check and uploads the video. ## Is there a CLI or SDK? Not yet. Use the REST API directly (examples in curl, TypeScript and Python throughout these docs) or the MCP server from a coding agent. See the [roadmap](https://akaphone-docs.pages.dev/roadmap/). ## How many devices can I run at once? 1 on Free, 3 on Dev (any purchase), 5 on Team. Contact us for more. See [Limits and quotas](https://akaphone-docs.pages.dev/guides/limits-and-quotas/). ## What happens if I forget to stop a device? It stops itself after 30 minutes without activity (no input, task, install or open live view), so a forgotten device costs at most about half an hour. It also stops when your balance runs out. ## Why did my device stop? Viewers of the live stream get a `device.stopped` event with the reason: `user` (you or your key stopped it), `idle` (30 minutes of no activity), `credits` (balance ran out) or `lost` (its host failed; unused prepaid minutes are refunded). ## Why is my restored device stuck in queued? Snapshots live on the host that took them, and restores must run there. If that host is full, the device waits for a slot. See [Snapshots](https://akaphone-docs.pages.dev/concepts/snapshots/#pinned-to-one-host). ## Is my data safe on a device? Each device is isolated in its own container and network, but devices from different accounts can share a host, and containers run with elevated privileges. Use test accounts and test data. Read [Security](https://akaphone-docs.pages.dev/security/) for the full picture. ## Where can I see what I've spent? **Usage** in the dashboard, or `GET /v1/usage`. Every credit in and out is in the ledger (**Billing › Credit ledger**, or `GET /v1/billing/ledger`). ## How do I get a refund? Email [hello@akaphone.com](mailto:hello@akaphone.com) with your order number. Refunds remove the matching share of the pack's credits. See [Billing](https://akaphone-docs.pages.dev/guides/billing/#refunds). ## How do I delete my account? Email [hello@akaphone.com](mailto:hello@akaphone.com) from the address you sign in with. ## How do I report a security issue? Email [security@akaphone.com](mailto:security@akaphone.com). See [Security](https://akaphone-docs.pages.dev/security/#reporting-a-vulnerability). --- # Changelog > What changed in AkaPhone, newest first. Source: https://akaphone-docs.pages.dev/changelog/ ## v1.0, 26 October 2026 AkaPhone is live at [https://akaphone.abhgnx-417.workers.dev](https://akaphone.abhgnx-417.workers.dev), with the API at `https://akaphone.abhgnx-417.workers.dev` and these docs at [https://akaphone-docs.pages.dev](https://akaphone-docs.pages.dev). **Devices** - Cloud Android 14 devices (`pixel_8`, `pixel_tablet` and `small_phone` profiles, listed by `GET /v1/profiles`) in the `eu-central` region, created with `POST /v1/devices` or **New device** in the dashboard. - Optional `wait` (up to 90 seconds, with `X-Wait-Timed-Out: 1` when it runs out) on creation; automatic queueing when capacity is busy, with a 10-minute limit. - Live screen streaming over WebSocket at 2 to 5 fps by plan, with tap, swipe, text and key input; single-use stream tokens for browser viewers. - Screenshots (`GET /v1/devices/:id/screenshot`) and REST input (`POST /v1/devices/:id/input`). - Idle stop after 30 minutes without activity. **Tasks** - Plain-English tasks driven by Google's open-source ARTEMIS agent, with your own Gemini, Anthropic or OpenAI key sent per request and never stored. - `flash` and `pro` profiles, live step events, one active task per device, cancel. - Artifacts for every task: screen recording, before and after screenshots, ARTEMIS trace and log. **Apps and snapshots** - APK install by drag and drop in the dashboard, or over the API with presigned uploads up to 300 MB and an install-status endpoint. - Snapshots of a device's disk and restores into new devices, with per-plan quotas (1, 5 or 20). **Agents** - `akaphone-mcp` 1.0.0, an MCP server for Claude Code, Cursor and Codex with 13 tools, including APK install, device input and profiles. - `/llms.txt`, `/llms-full.txt` and a Markdown version of every docs page. **Account and billing** - Google sign-in, API keys (`ak_live_…`, stored hashed, up to 25 per account). - Prepaid credits: 60 free minutes on signup, packs of 250, 1,375 and 6,000 minutes, and a Team pack. Free, Dev and Team plans with 1, 3 and 5 concurrent devices. - Usage by day and device, and a full credit ledger. --- # Roadmap > Planned work that is not available yet. Nothing on this page exists today, and plans can change. Source: https://akaphone-docs.pages.dev/roadmap/ > **Planned, not available** > > Everything on this page is **planned**. None of it works today, there are no dates, and the order may change. For what exists now, see the rest of the docs and the [changelog](https://akaphone-docs.pages.dev/changelog/). ## Platform - **Per-account host isolation.** Run each account's devices on hosts that no other account uses, closing the shared-host risk described in [Security](https://akaphone-docs.pages.dev/security/#device-isolation). - **Snapshots on shared storage.** Store snapshots off the host so any host can restore them: no more `lost` snapshots, and restores do not have to wait for one specific host. - **More regions** beyond `eu-central`, including the United States and Asia. - **More Android versions and device profiles**, chosen when you create a device. - **Images with Google Play Services**, for apps that need the Play Store or Play Integrity. - **Physical devices**: real phones in a device rack, used through the same API. - **iOS devices.** ## Developer tools - **A `pf` command-line tool** for CI and scripts, wrapping the REST API. - **Official SDKs** for TypeScript and Python. - **Webhooks** for task and device events, so CI does not have to poll. - **Per-task model choice**, instead of the host's default model for each provider and profile. ## Teams and accounts - **Workspaces** with members, roles and shared snapshots. - **Single sign-on (SSO)** and an audit log you can export. - **Scoped API keys**, for example read-only keys or keys limited to tasks. Have a request? Email [hello@akaphone.com](mailto:hello@akaphone.com).