Which account this key belongs to, and the ids of the devices it controls. A good first call to check your key works.
curl -s -H "X-API-Key: $ERIUS_KEY" \
$ERIUS_API/whoami{
"tenant": "your-team",
"keyId": "k1",
"devices": ["dev_7f3a"]
}Docs · Device API
Every Erius Phone device is driven through one small JSON API for AI agent mobile automation: read the screen, tap, type, swipe, press keys, open apps and install your APK. This page documents the API as it runs today.
Overview
Each device is a real Android 13 phone running in its own isolated container. The API gives your agent the same surface a person has — the touchscreen and the hardware keys — plus a structured read of whatever is on screen. Any language or agent framework that can make an HTTP request can use it.
Requests are JSON (Content-Type: application/json). Responses are JSON, except /screenshot, which returns a PNG, and /snapshot?format=text, which returns plain text.
Every URL that acts on a phone starts with /device/:id. Your device ids come from GET /whoami or GET /devices. During early access, devices are provisioned for you — there's no create-device endpoint.
/snapshot returns the accessibility tree, with [ref=N] on everything you can act on. Pass {"ref": N} to tap it. Refs reset on every snapshot, so act on a fresh one.
Instead of a ref, you can target by label: {"text": "Search"} or {"contentDesc": "Settings"}. The API takes a fresh snapshot, picks the best match, and taps its nearest tappable parent.
For anything the tree can't see (WebViews, Flutter, games), take a /screenshot and tap with {"x", "y"} in screen pixels (1440×3120 on a phone), or in the pixels of a scaled image with "of": [imageWidth, imageHeight]. /observe returns the image and the tree in one call.
Authentication
Every request except GET /healthz needs your API key, sent as X-API-Key: <key> or as Authorization: Bearer <key>. A missing or unknown key gets 401 unauthorized.
Each key belongs to exactly one account. It can see and control only that account's devices: /devices lists only yours, and any device id that isn't yours — whether or not it exists — gets the same 403 forbidden response, so other accounts' devices can't be discovered by guessing ids.
We store only a hash of your key, never the key itself, and keys are never written to logs or returned by the API. An account can hold more than one key, so a key can be rotated without downtime: we issue the new one, you switch over, the old one is revoked.
Getting a key.
During early access we issue keys personally, together with your device. Request early access and we'll get in touch. Once you have a key, the dashboard shows your phones live: sign in with the same key.
Getting started
The basic agent loop in five calls: find your phone, see the screen, tap something, check what happened.
# the key you received with early access
export ERIUS_KEY="<your api key>"
export ERIUS_API="https://api.eriusphone.com"
# 1. which devices does this key control?
curl -s -H "X-API-Key: $ERIUS_KEY" $ERIUS_API/whoami
# → {"tenant": "your-team", "keyId": "k1", "devices": ["dev_7f3a"]}
# 2. take a screenshot (full-size PNG; add ?format=jpeg&scale=0.5 for a small, fast one)
curl -s -H "X-API-Key: $ERIUS_KEY" -o step_01.png $ERIUS_API/device/dev_7f3a/screenshot
# 3. tap a coordinate you picked from the screenshot
curl -s -H "X-API-Key: $ERIUS_KEY" -H 'Content-Type: application/json' \
-d '{"x": 360, "y": 640}' $ERIUS_API/device/dev_7f3a/tap
# → {"ok": true}
# 4. or open an app, read the screen as a tree and tap by label instead
curl -s -H "X-API-Key: $ERIUS_KEY" -H 'Content-Type: application/json' \
-d '{"package": "com.android.settings"}' $ERIUS_API/device/dev_7f3a/stop
curl -s -H "X-API-Key: $ERIUS_KEY" -H 'Content-Type: application/json' \
-d '{"package": "com.android.settings"}' $ERIUS_API/device/dev_7f3a/launch
curl -s -H "X-API-Key: $ERIUS_KEY" "$ERIUS_API/device/dev_7f3a/snapshot?format=text"
# → … - Group [ref=4] - Text "Network & internet" …
curl -s -H "X-API-Key: $ERIUS_KEY" -H 'Content-Type: application/json' \
-d '{"text": "Network & internet"}' $ERIUS_API/device/dev_7f3a/tap
# → {"ok": true}
# 5. screenshot again to see the result
curl -s -H "X-API-Key: $ERIUS_KEY" -o step_02.png $ERIUS_API/device/dev_7f3a/screenshot
Device id dev_7f3a and tenant name are examples; use the ones returned by /whoami.
MCP server
The erius-phone-mcp package turns the API into 21 tools for Claude Code, Claude Desktop, Cursor and any other MCP client. It runs on your machine and talks to the API with your key.
claude mcp add erius-phone \
-e ERIUS_PHONE_API_KEY=your-api-key \
-e ERIUS_PHONE_DEFAULT_DEVICE=your-device-id \
-- uvx erius-phone-mcp
{
"mcpServers": {
"erius-phone": {
"command": "uvx",
"args": ["erius-phone-mcp"],
"env": {
"ERIUS_PHONE_API_KEY": "your-api-key",
"ERIUS_PHONE_DEFAULT_DEVICE": "your-device-id"
}
}
}
}
Install build/app-release.apk on my ERIUS PHONE device and launch it.
Go through the sign-up screen with the test account from .env.test, then
open the profile tab. After each step take a snapshot and tell me what
you see. Stop and report if the app crashes; include the crash log.
Without uv, run pip install erius-phone-mcp in a virtual environment and use the full path of the installed erius-phone-mcp command. Source, the full tool list and troubleshooting: github.com/Protremix/erius-phone-mcp · PyPI. Checked on 10 October 2026: a clean pip install, the tool list and the read tools against a production phone.
Integration examples
Both ran against production phones on 10 October 2026. Neither needs a library beyond the language's own.
Installs the APK, launches it, reads the screen, saves a screenshot and exits non-zero on a crash. Add it as a step after your build job.
#!/usr/bin/env python3
"""Smoke-test an Android build on an ERIUS PHONE device. Standard library only.
Installs the APK, launches it, reads the screen, checks for a crash and saves a screenshot.
Exits 0 when the app is in the foreground with no crash, 1 otherwise, so it can gate a CI job.
export ERIUS_PHONE_API_KEY=... # the key you received with early access
python3 apk_smoke_test.py app-release.apk [device-id]
"""
import json
import os
import sys
import urllib.error
import urllib.request
API = os.environ.get("ERIUS_PHONE_API_URL", "https://api.eriusphone.com")
KEY = os.environ["ERIUS_PHONE_API_KEY"]
def call(method, path, body=None, raw=None, timeout=300):
"""One API call. Returns (status, parsed JSON or raw bytes)."""
headers = {"X-API-Key": KEY}
data = raw
if body is not None:
data = json.dumps(body).encode()
headers["Content-Type"] = "application/json"
elif raw is not None:
headers["Content-Type"] = "application/vnd.android.package-archive"
req = urllib.request.Request(API + path, data=data, method=method, headers=headers)
try:
with urllib.request.urlopen(req, timeout=timeout) as r:
status, payload, ctype = r.status, r.read(), r.headers.get("Content-Type", "")
except urllib.error.HTTPError as e:
status, payload, ctype = e.code, e.read(), e.headers.get("Content-Type", "")
return status, (json.loads(payload) if "json" in ctype else payload)
def main():
apk = sys.argv[1]
status, me = call("GET", "/whoami")
if status != 200:
sys.exit(f"auth failed: {status} {me}")
device = sys.argv[2] if len(sys.argv) > 2 else me["devices"][0]
dev = f"/device/{device}"
# 1. install the build (the raw APK is the request body)
with open(apk, "rb") as f:
status, inst = call("POST", f"{dev}/install", raw=f.read())
if status != 200 or not inst.get("ok"):
sys.exit(f"install failed: {status} {inst.get('error') or inst}")
package = inst["package"]
print(f"installed {package} {inst.get('versionName')} in {inst.get('ms')} ms")
# 2. launch it; the response says what is in the foreground afterwards
status, launched = call("POST", f"{dev}/launch", {"package": package})
if status != 200:
sys.exit(f"launch failed: {status} {launched}")
# 3. look at the screen: the accessibility tree plus a list of elements, no image
status, seen = call("GET", f"{dev}/observe?image=0")
fg = seen["foreground"]
interactive = [e for e in seen["elements"] if e.get("clickable") or e.get("editable")]
print(f"foreground: {fg['package']} | {len(interactive)} interactive elements | tree coverage: {seen['treeCoverage']}")
# 4. keep a small screenshot as the build artifact
status, shot = call("GET", f"{dev}/screenshot?format=jpeg&quality=70&scale=0.5")
with open("smoke.jpg", "wb") as f:
f.write(shot)
print(f"saved smoke.jpg ({len(shot) // 1024} KB)")
# 5. verdict: a crash dialog, or another app in front (a permission prompt counts as ours)
crashed = fg.get("crashDialog") and fg.get("crashedPackage") == package
in_front = fg.get("package") in (package, "com.android.permissioncontroller",
"com.google.android.permissioncontroller")
if crashed or not in_front:
_, log = call("GET", f"{dev}/crashes?lines=40")
print("\n".join(l for l in log.get("lines", []) if package in l or "FATAL" in l)[-2000:])
sys.exit(1)
print("PASS")
if __name__ == "__main__":
main()
Opens Settings, taps a row by its visible text and waits for the next screen, on every device of the key in parallel. Replace the five steps in flow() with your own.
// Run the same UI flow on several ERIUS PHONE devices at the same time. Node 18+, no dependencies.
//
// export ERIUS_PHONE_API_KEY=...
// node fleet_flow.mjs # every device this key controls
// node fleet_flow.mjs dev_a dev_b # or only the ones you name
//
// Each device takes one action at a time; different devices run in parallel.
const API = process.env.ERIUS_PHONE_API_URL || 'https://api.eriusphone.com';
const KEY = process.env.ERIUS_PHONE_API_KEY;
async function api(method, path, body) {
for (let attempt = 0; ; attempt++) {
const r = await fetch(API + path, {
method,
headers: { 'X-API-Key': KEY, ...(body ? { 'Content-Type': 'application/json' } : {}) },
body: body ? JSON.stringify(body) : undefined,
});
// 429 = over the rate limit, or the phone's queue is full: back off and retry
if (r.status === 429 && attempt < 5) { await new Promise((s) => setTimeout(s, 250 * (attempt + 1))); continue; }
const json = (r.headers.get('content-type') || '').includes('json');
return { status: r.status, data: json ? await r.json() : Buffer.from(await r.arrayBuffer()) };
}
}
// The flow: open Settings, find a row by its visible text, open it, confirm the next screen.
async function flow(device) {
const dev = `/device/${device}`;
const t0 = Date.now();
const step = async (name, method, path, body) => {
const r = await api(method, dev + path, body);
if (r.status !== 200) throw new Error(`${name}: ${r.status} ${r.data.error || ''} ${r.data.message || ''}`);
return r.data;
};
try {
await step('stop', 'POST', '/stop', { package: 'com.android.settings' });
await step('launch', 'POST', '/launch', { package: 'com.android.settings' });
await step('wait', 'POST', '/wait', { text: 'Network & internet', timeout: 10000 });
await step('tap', 'POST', '/tap', { text: 'Network & internet' });
await step('wait', 'POST', '/wait', { text: 'Internet', timeout: 10000 });
const fg = await step('foreground', 'GET', '/foreground');
return { device, ok: true, ms: Date.now() - t0, foreground: fg.package };
} catch (e) {
return { device, ok: false, ms: Date.now() - t0, error: e.message };
} finally {
await api('POST', dev + '/press', { key: 'home' });
await api('POST', dev + '/stop', { package: 'com.android.settings' });
}
}
const me = (await api('GET', '/whoami')).data;
const devices = process.argv.length > 2 ? process.argv.slice(2) : me.devices;
const t0 = Date.now();
const results = await Promise.all(devices.map(flow));
for (const r of results) console.log(r.ok ? 'PASS' : 'FAIL', r.device, `${r.ms} ms`, r.ok ? r.foreground : r.error);
console.log(`${devices.length} device(s), ${Date.now() - t0} ms wall clock`);
process.exit(results.every((r) => r.ok) ? 0 : 1);
Errors
HTTP/1.1 403 Forbidden
{
"ok": false,
"error": "forbidden",
"message": "device not found or not accessible with this key",
"requestId": "3c3eb4a4f9151d4d"
}
| Status | Codes you'll see | Meaning |
|---|---|---|
400 | bad_request, bad_json, unsupported_text, not_an_apk, bad_upload, not_audio, not_a_video | Bad input — the message says which field. |
401 | unauthorized | Missing or wrong API key. |
403 | forbidden | Device isn't yours, or doesn't exist (deliberately the same). |
404 | SelectorNotFound, ElementNotFound, not_installed, not_found | Nothing on screen matched, unknown ref, package not installed, unknown route. |
408 | WaitTimeout | /wait didn't see the text in time. |
409 | input_rejected, mic_unsupported, camera_unsupported | input_rejected: the screen was changing and did not take the touch (rare, mostly swipes) — look again and retry. The other two: this device has no virtual microphone or camera. Check the mic / camera flags in /devices. |
413 | too_large | JSON body over 1 MB, or APK upload over the 1 GB cap. |
422 | not_interactive, no_launcher_activity, no_focused_field, password_field, install failures | The request was valid but the action failed on the device. |
429 | rate_limited, device_busy | Over the request rate for your key, or more than 8 requests already queued for one phone. A rate_limited response carries Retry-After: 1. Back off and retry. |
500 | internal | Our bug. Retry once; if it persists, send us the requestId. |
503 | device_offline | The phone isn't reachable right now (e.g. rebooting). Retry shortly. |
504 | adb_timeout, action_timeout | The device took too long to respond. Take a fresh snapshot before retrying. |
Limits & caveats
UI actions on a device run in order, so a snapshot's refs stay valid for the next tap. Different devices run in parallel. Refs are shared by everyone driving the same device.
The limit is per API key and per phone, not per IP address: 60 requests per second for each phone (bursts to 120), so several phones driven from one machine do not share a budget. Calls that name no phone (/whoami, /devices) have their own 60 per second per key. Above that you get 429 rate_limited with Retry-After: 1. A backstop of 300 requests per second per IP address applies across all keys, and requests without a key are limited to 30 per second per address. Each phone queues at most 8 waiting requests; more get 429 device_busy. No single action holds a phone longer than 150 s. Every response from the API carries X-Request-Id; quote it if you report a problem.
Reading the accessibility tree takes about 30 ms on a simple screen and about 70 ms on a busy one (medians over the public API, measured 10 October 2026). The first call after a phone has sat idle takes about 0.25 s. You still don't need to poll in a loop — /wait re-reads the screen about ten times a second for you.
/type sends ASCII as key presses and inserts anything else (accents, Cyrillic, Hebrew, CJK, emoji) at the cursor, so both add to what the field already holds. One exception: non-ASCII text can't be added to a password field, because its content isn't readable (422 password_field); send "clear": true to set the whole field instead.
WebViews, Flutter apps and games often show little or nothing in the snapshot. Use /screenshot plus {"x", "y"} taps there.
Account sign-ins (Google and others) are done once by a person, by design. The agent works inside the session you set up and never handles your passwords or 2FA.
The phones aren't Play Protect certified, so some banking, DRM-streaming and game apps won't run. We'll tell you plainly when an app is one of them.
Reference · Account & devices
Which account this key belongs to, and the ids of the devices it controls. A good first call to check your key works.
curl -s -H "X-API-Key: $ERIUS_KEY" \
$ERIUS_API/whoami{
"tenant": "your-team",
"keyId": "k1",
"devices": ["dev_7f3a"]
}Your devices with live status: whether the phone is online and booted, its Android version and screen size. Only your own devices are listed.
curl -s -H "X-API-Key: $ERIUS_KEY" \
$ERIUS_API/devices{
"devices": [
{
"id": "dev_7f3a",
"tenant": "your-team",
"state": "device",
"bootCompleted": true,
"android": "13",
"sdk": 33,
"screen": { "width": 720, "height": 1280 },
"mic": true,
"camera": false
}
]
}state is "device" when the phone is online. mic and camera say whether the device supports /mic and /camera. Entries may also carry internal fields (such as serial); don't build on those.
Same as one entry of /devices, plus foreground: the app and window currently on screen (see /foreground).
Reference · Reading the screen
A fresh screenshot of the phone. The default is a full-size image/png (1440×3120 on a phone, about 3 MB). For an agent loop ask for a smaller image: format (png, jpeg or webp), quality (1 to 100), scale (0.1 to 1) and maxSide (longest side in pixels, 64 to 8192) are query parameters. ?format=jpeg&quality=70&scale=0.5 returns about 40 KB. The response headers X-Device-Width, X-Device-Height and X-Image-Scale give the phone's real size, and a tap can use image coordinates with {"x", "y", "of": [imageWidth, imageHeight]}. Accepts GET or POST.
curl -s -H "X-API-Key: $ERIUS_KEY" \
-o shot.png \
$ERIUS_API/device/dev_7f3a/screenshotContent-Type: image/png
Cache-Control: no-store
<PNG bytes · the phone's full resolution>The accessibility tree of the current screen, with [ref=N] on every element you can act on, plus what's in the foreground. Add ?format=text to get just the tree as plain text.
| Param | Description |
|---|---|
format | text → plain-text tree only. Default: JSON. |
maxDepth, maxNodes | Optional numbers to trim very large trees. |
curl -s -H "X-API-Key: $ERIUS_KEY" \
$ERIUS_API/device/dev_7f3a/snapshot{
"foreground": {
"package": "org.fdroid.fdroid",
"activity": "org.fdroid.IconActivity",
"window": "org.fdroid.fdroid/org.fdroid.IconActivity",
"crashDialog": false,
"crashedPackage": null
},
"snapshot": "- ScrollView [ref=1]\n - View\n - View [ref=2]\n - Text \"New apps\"\n ..."
}- ScrollView [ref=1]
- View
- View [ref=2]
- Text "New apps"
- View [ref=3]
- View [ref=4]
- View [ref=5]
- Text "ADB Captain"
- View [ref=6]
- Text "Starling"
- View
- Text "F-Droid"
- View [ref=18]
- View (Repositories)
- View [ref=19]
- View (Settings)
Quoted strings are visible text; parentheses are content descriptions (labels on icons). Refs are only valid until the next snapshot.
One call to look at the phone: a screenshot with a numbered mark drawn on every element you can act on, the list of those elements, the accessibility tree and what is in the foreground. The number on a mark is the element's ref, so a model that reads the image can answer "tap 7" and you send {"mark": 7} to /tap. The image is a JPEG at quality 75 unless you say otherwise, returned as base64 inside the JSON.
| Param | Description |
|---|---|
format, quality, scale, maxSide | Same as /screenshot. ?maxSide=768 gives a 355×768 image of a phone, about 21 KB. |
image=0 | Leave the image out: elements and tree only (about 3 KB, around 50 ms). |
allWindows=1 | Read every window on screen, not only the app in front (for example the keyboard or a system dialog). |
curl -s -H "X-API-Key: $ERIUS_KEY" \
"$ERIUS_API/device/dev_7f3a/observe?maxSide=768"{
"foreground": { "package": "com.android.settings", ... },
"elements": [
{
"ref": 2, "role": "ViewGroup",
"clickable": true, "scrollable": false, "editable": false,
"bounds": { "x1": 51, "y1": 633, "x2": 1389, "y2": 797 },
"center": { "x": 720, "y": 715 }
},
...
],
"snapshot": "- ScrollView [ref=1]\n ...",
"treeCoverage": "ok",
"treeScreenShare": 0.38,
"screen": { "width": 1440, "height": 3120 },
"image": {
"type": "image/jpeg",
"width": 355, "height": 768, "scale": 0.2465,
"marked": true,
"base64": "/9j/4AAQ..."
}
}bounds and center are in screen pixels, whatever the image size. treeCoverage is "thin" when the tree describes little of the screen (games, canvas, some WebViews); the response then carries a hint, and you should tap by {"x", "y", "of": [image.width, image.height]} from the image instead. Refs and marks are only valid until the next snapshot or observe.
What's on screen right now, without reading the whole tree. crashDialog is true when Android is showing an "app has stopped" or "isn't responding" dialog, with the crashed package in crashedPackage.
curl -s -H "X-API-Key: $ERIUS_KEY" \
$ERIUS_API/device/dev_7f3a/foreground{
"package": "com.android.settings",
"activity": "com.android.settings.Settings",
"window": "com.android.settings/com.android.settings.Settings",
"crashDialog": false,
"crashedPackage": null
}Block until the given text appears on screen, then return the snapshot. timeout is in milliseconds (default 10000, max 120000); on timeout you get 408 WaitTimeout.
curl -s -H "X-API-Key: $ERIUS_KEY" \
-H 'Content-Type: application/json' \
-d '{"text": "Recently updated", "timeout": 15000}' \
$ERIUS_API/device/dev_7f3a/wait{
"ok": true,
"snapshot": "- ScrollView [ref=1]\n ..."
}Reference · Input
All input endpoints return {"ok": true} on success.
Tap an element or a point. Pass exactly one way of choosing what to tap.
| Body | Taps |
|---|---|
{"ref": 12} | Element with that ref in the latest snapshot. |
{"text": "Search"} | Best match on visible text: exact, then case-insensitive, then substring. |
{"contentDesc": "Settings"} | Best match on an element's content description (icon label). |
{"target": {"text": "…"}} | Same as above, nested form. |
{"mark": 7} | Element with that number in the latest /observe image. Same as ref. |
{"x": 360, "y": 640} | A point in screen pixels. |
{"x": 180, "y": 320, "of": [355, 768]} | A point in the pixels of an image of that size (a scaled screenshot). The API maps it to the screen. |
| Option | Effect |
|---|---|
"verify": true | Wait for the screen to react and report it: the response adds screenChanged, changedRatio (share of the screen that changed, 0 to 1) and reactionMs. A number instead of true sets the wait in milliseconds (100 to 5000). Use it to catch a tap that landed on nothing. |
"human": true | Tap the way a finger does: a point inside the element rather than its exact centre, held for a natural length. The response adds the point used and holdMs. Also accepted by /longpress and /type (key presses at a human pace). |
// body
{ "mark": 2, "human": true, "verify": 800 }
// response
{
"ok": true,
"screenChanged": true,
"changedRatio": 0.0026,
"reactionMs": 9,
"x": 554, "y": 718,
"holdMs": 106
}curl -s -H "X-API-Key: $ERIUS_KEY" \
-H 'Content-Type: application/json' \
-d '{"text": "Search settings"}' \
$ERIUS_API/device/dev_7f3a/tap{ "ok": true }
// nothing matched
{
"ok": false,
"error": "SelectorNotFound",
"message": "nothing on screen matches {\"text\":\"Serch\"}"
}Same targets as /tap. With coordinates, ms sets how long to hold (default 800).
{ "x": 360, "y": 900, "ms": 1200 }Type into the focused field — or tap a target first, then type. clear empties the field before typing; enter presses Enter afterwards. Any script works; text is added at the cursor unless you send clear.
| Field | Description |
|---|---|
text | Required. ASCII text is typed key by key (\n presses Enter); text with any other character is inserted at the cursor in one step. |
ref / target | Optional element to tap before typing. |
clear, enter | Optional booleans. |
curl -s -H "X-API-Key: $ERIUS_KEY" \
-H 'Content-Type: application/json' \
-d '{"text": "battery saver",
"target": {"text": "Search settings"},
"clear": true, "enter": true}' \
$ERIUS_API/device/dev_7f3a/type{ "ok": true }
// non-ASCII text added to a password field
{
"ok": false,
"error": "password_field",
"message": "non-ASCII text cannot be added to a password field (its content is not readable); send \"clear\": true to replace the whole field"
}Press a hardware or system key. key is a name — back home enter delete tab escape up down left right space power volup voldown recent menu wakeup sleep search — or a numeric Android keycode.
curl -s -H "X-API-Key: $ERIUS_KEY" \
-H 'Content-Type: application/json' \
-d '{"key": "back"}' \
$ERIUS_API/device/dev_7f3a/press{ "ok": true }Swipe from one point to another in screen pixels. ms is the gesture duration (default 300).
curl -s -H "X-API-Key: $ERIUS_KEY" \
-H 'Content-Type: application/json' \
-d '{"x1": 360, "y1": 1000,
"x2": 360, "y2": 300, "ms": 400}' \
$ERIUS_API/device/dev_7f3a/swipe{ "ok": true }Scroll a specific scrollable element, chosen by ref, target or text. direction is up, down, left or right.
{ "ref": 1, "direction": "down" }Turn the screen, or read how it is turned. /rotate takes {"orientation": "landscape"} or "portrait", or an exact {"rotation": 0-3} in quarter turns. It waits for the turn and the redraw before answering (between one and four seconds), so a screenshot taken straight after shows the new layout. In landscape, screen, screenshots and tap coordinates all use the rotated size. GET /rotation returns the same fields without changing anything.
curl -s -H "X-API-Key: $ERIUS_KEY" \
-H 'Content-Type: application/json' \
-d '{"orientation": "landscape"}' \
$ERIUS_API/device/dev_7f3a/rotate{
"ok": true,
"applied": true,
"requested": 1,
"rotation": 1,
"orientation": "landscape",
"screen": { "width": 3120, "height": 1440 },
"naturalOrientation": "portrait"
}applied is false when the app in front fixes its own orientation (the home screen does): the response then carries a note, and the new rotation takes effect as soon as an app that allows it is shown. The setting stays until you change it, so turn the phone back to portrait when you are done.
Reference · Apps
Open an installed app by package name (optionally a specific activity), or open a URL — http, https or market — optionally in a given package. Returns how the launch went and what ended up in the foreground.
curl -s -H "X-API-Key: $ERIUS_KEY" \
-H 'Content-Type: application/json' \
-d '{"package": "org.fdroid.fdroid"}' \
$ERIUS_API/device/dev_7f3a/launch
# or open a link
-d '{"url": "https://example.com"}'{
"ok": true,
"component": "org.fdroid.fdroid/org.fdroid.IconActivity",
"launchState": "COLD",
"totalTimeMs": 262,
"foreground": {
"package": "org.fdroid.fdroid",
"activity": "org.fdroid.IconActivity",
"window": "org.fdroid.fdroid/org.fdroid.IconActivity",
"crashDialog": false,
"crashedPackage": null
}
}Errors: 404 not_installed if the package isn't on the phone; 422 no_launcher_activity if it has no launcher entry (pass activity).
Start an Android intent directly, for deep links and app-specific entry points. extras values are sent as boolean, integer or string extras according to their JSON type. Returns {ok, output, foreground}.
{
"action": "android.intent.action.VIEW",
"data": "myapp://orders/42",
"package": "com.example.myapp",
"extras": { "from_test": true, "retry": 2 }
}Install an APK on the phone. Upload it as the raw request body or as a multipart file field; reinstalls over an existing version. The API reads the APK first and returns its package details along with the result.
| Option | Description |
|---|---|
| raw body | --data-binary @app.apk |
| multipart | -F apk=@app.apk |
?grant=1 | Grant all runtime permissions at install. |
?downgrade=1 | Allow installing an older version over a newer one. |
curl -s -H "X-API-Key: $ERIUS_KEY" \
-F apk=@F-Droid.apk \
"$ERIUS_API/device/dev_7f3a/install?grant=1"{
"ok": true,
"device": "dev_7f3a",
"package": "org.fdroid.fdroid",
"versionCode": "2000051",
"versionName": "2.0.1",
"label": "F-Droid",
"launchActivity": "org.fdroid.MainActivity",
"minSdk": 24,
"targetSdk": 37,
"abis": ["arm64-v8a", "armeabi-v7a", "x86", "x86_64"],
"adbOutput": "Performing Streamed Install\nSuccess",
"ms": 957
}Uploads up to 1 GB. A file that isn't an APK returns 400 not_an_apk; an install Android rejects returns 422 with Android's reason in error (e.g. INSTALL_FAILED_…). ARM-only APKs install too.
Installed packages. ?thirdParty=1 limits the list to apps that were installed on top of the system — usually the ones you care about.
curl -s -H "X-API-Key: $ERIUS_KEY" \
"$ERIUS_API/device/dev_7f3a/apps?thirdParty=1"{
"count": 2,
"packages": [
"org.cromite.cromite",
"org.fdroid.fdroid"
]
}Without thirdParty=1 the response is {count, thirdPartyCount, packages: [{package, thirdParty}]} for every package on the phone.
Uninstall a package.
curl -s -H "X-API-Key: $ERIUS_KEY" -X DELETE \
$ERIUS_API/device/dev_7f3a/apps/org.fdroid.fdroid{
"ok": true,
"package": "org.fdroid.fdroid",
"output": "Success"
}Force-stop an app, e.g. to start the next test from a cold launch. Body: {"package": "org.fdroid.fdroid"} → {"ok": true}.
Recent lines from Android's crash log — Java FATAL EXCEPTIONs and native crashes — so your agent can tell a crash from a slow screen. ?lines= sets how many (default 200, max 5000).
curl -s -H "X-API-Key: $ERIUS_KEY" \
"$ERIUS_API/device/dev_7f3a/crashes?lines=50"{
"lines": [
"E AndroidRuntime: FATAL EXCEPTION: main",
"E AndroidRuntime: Process: com.example.myapp, PID: 4121",
"E AndroidRuntime: java.lang.IllegalStateException: …",
"…"
]
}Example lines are illustrative of the format; an empty lines array means no crashes were logged.
GET /healthz needs no key and returns {"ok": true}; use it to check the API is reachable.
Reference · Microphone & camera
Upload audio and it plays through the phone's microphone, so any app that records — a voice assistant, a recorder, a voice verification step — hears it as real input. These calls don't wait behind taps and swipes on the same phone.
Play an audio clip into the phone's microphone. Send the file as the raw request body or as a multipart audio field; any common format works. It's converted to 48 kHz mono and starts from the beginning the next time an app reads the mic. A new upload replaces the previous clip.
| Option | Description |
|---|---|
| raw body | --data-binary @clip.mp3 |
| multipart | -F audio=@clip.mp3 |
?loop=1 | Repeat the clip until stopped. Default: play once, then silence. |
curl -s -H "X-API-Key: $ERIUS_KEY" \
-F audio=@clip.mp3 \
$ERIUS_API/device/dev_7f3a/mic{
"ok": true,
"bytes": 729600,
"durationMs": 7600,
"loop": false,
"status": { "state": "queued", … },
"ms": 292
}Clips longer than 600 s are cut at 600 s ("truncated": true). A file with no decodable audio returns 400 not_audio; a device without a virtual microphone returns 409 mic_unsupported. The response also includes device and the output format.
What the microphone is doing right now, and whether an app on the phone is currently recording.
| Field | Description |
|---|---|
state | idle (nothing uploaded), queued (uploaded, no app has read it yet), playing, done (played to the end), live. |
playedMs, totalMs | Playback position and clip length. |
loop | Whether the clip repeats. |
recording | true while an app is reading the microphone. |
curl -s -H "X-API-Key: $ERIUS_KEY" \
$ERIUS_API/device/dev_7f3a/mic/status{
"state": "playing",
"playedMs": 2880,
"totalMs": 7600,
"recording": true,
…
}Stop playback and remove the clip; the microphone goes back to silence. Returns {"ok": true} with the new status ("state": "idle").
curl -s -H "X-API-Key: $ERIUS_KEY" -X DELETE \
$ERIUS_API/device/dev_7f3a/micBeta: new devices only, for now.
The virtual camera is available on newly provisioned devices on request; existing devices don't have it yet. Check the camera flag in /devices; without it these calls return 409 camera_unsupported. Behaviour may still change.
Show a video clip to the phone's camera. POST a clip as the raw body or as a multipart video field: it's test-decoded first, so a file that can't be played never replaces a working feed, then it loops as the camera picture (1280×720, 30 fps) for any app that opens the camera. GET shows the current feed; DELETE removes it and the camera shows a test card again.
curl -s -H "X-API-Key: $ERIUS_KEY" \
-F video=@clip.mp4 \
$ERIUS_API/device/dev_7f3a/camera{
"device": "dev_7f3a",
"beta": true,
"feeding": true,
"output": {
"size": "1280x720", "fps": 30, "format": "MJPEG"
},
"feed": {
"bytes": 191671,
"codec": "h264",
"width": 1920, "height": 1080,
"durationMs": 8000,
…
}
}Uploads up to 300 MB. A file with no playable video returns 400 not_a_video. "feed": null means the test card is showing. POST and DELETE return the same fields plus ok.
API keys are issued by hand during early access, together with your phone.
Request a device