API reference
Base URL: https://previewapk.siteport.site. Everything the portal does, the API does. All responses are JSON unless noted; errors are {"error": "…"} with the HTTP status carrying the meaning.
Authentication
Send your account key on every call:
X-Api-Key: <your key>
Calls with the key act as the agent caller. (The portal's browser session cookie acts as the human caller on the same endpoints.) The distinction matters for exactly one thing: driver gating on input. A missing or wrong key returns 401.
Key scopes and expiry
Every key carries a scope, chosen when the key is created (in the portal, or via the keys API with an admin key):
| Scope | Can do |
|---|---|
agent (default) | Everything a key could do before scopes existed: observe, drive, upload, manage devices. |
readonly | Observe only: state, hierarchy / observe, screenshots, logs, session links, recording status + download, file list / pull. Every driving or mutating call is refused with 403 and "code": "key_readonly". |
admin | Everything agent can, plus the keys API (create / revoke keys) — the only key-authorized management surface. The portal UI itself stays cookie-only. |
Keys can also expire: never, or after 30 / 90 / 365 days, chosen at creation. An expired key returns 401 with "code": "key_expired". Keys created before scopes existed are agent scope with no expiry. The account's key list (portal, and state for human callers) shows each key's scope, expiry, and last-used time.
Activity log
Every API-key call is recorded in the account's activity log: time, key, action, device, HTTP result, request id — action names and metadata only, never request bodies or typed text. Entries are kept for 30 days. Human callers see the latest 50 in GET /portal/api/state (activity) and in the portal's Activity card.
State
GET /portal/api/state
Everything about your account: devices, recent uploads, and (for human callers only) the API key.
{
"devices": [ { …device object… } ],
"recents": [ {"fid": "…", "label": "InkSync", "size": 62914560, …} ]
}
The device object
| Field | Meaning |
|---|---|
id, name | Device id (e.g. dev1) and its display name |
profile | pixel (Pixel 9 Pro, 712×1600) or galaxy (Galaxy S24, 736×1600) |
android | Android version string, "10"–"15" |
status | off · starting · live · error |
driver | user or agent — who holds the wheel in the current (or next) session |
apk | null, or {label, package, version_name, version_code, sha256, size, filename, icon, ts} for the attached app — a fingerprint of the uploaded bytes — identical bytes, identical fingerprint |
driver_changed_at, driver_note | When the wheel last changed hands, and why — e.g. "the user took the wheel in the portal at 3:42 PM ET". How an agent learns it was interrupted, instead of guessing. |
handoff | null, or {pending: true, reason, requested_at} while the driving agent is waiting for a human step (see request-human below). Cleared by the handback or the session's end. |
finish_report | null, or the agent's filed wrap-up for the live session: {summary, verdict, counts: {duration_s, actions, errors}, recording, sid, ts} — the counts counted by the server from the session's own record. Also sealed into the session archive when the session ends. |
handback_note, resumed | Agent callers only, delivered exactly once: on the first state read (or input result) after the human hands the wheel back, the device carries the human's handback note and resumed: true — then both clear. Human callers never see or consume them. |
view_url | Session link while live, else null |
watching | Current viewer count on the session link |
idle_left | Seconds until idle auto-stop while live, else null |
error | Last error string, if any |
Upload a build
POST /portal/api/upload
Multipart form: file field apk, required text field device (device id or name). Each upload is identified as it lands: the package, versionCode, and versionName are read from the APK's manifest. Uploading bytes the account already has stored reuses the stored copy instead of storing it twice ("deduped": true). Uploads attach to the named device, replacing its current app. If the device is running and the caller holds the wheel, the new build is hot-swapped in — installed and relaunched inside the live session, no reboot — and the response gains "swapped": true. While the device is starting, the call returns 409. There is deliberately no default device: a call that doesn't name its device fails (400) — agents list devices first, then name one, so a push can never land on the wrong device by assumption.
curl -H "X-Api-Key: $KEY" -F apk=@app.apk -F device=dev1 \
https://previewapk.siteport.site/portal/api/upload
→ {"ok": true, "device_id": "dev1", "deduped": false,
"app": {"label": "InkSync", "package": "com.InkSync", "version_name": "1.0.1", "version_code": 101}}
Install verification & the downgrade rule
After any install into a live session (a hot swap, or the install that starts a session), the engine reads the installed package back with dumpsys and compares its versionCode with the uploaded APK's. A mismatch fails the call with "code": "install_version_mismatch" and the expected/installed values in details — a swap never silently lands a different build than the one you uploaded.
Installing an older versionCode over the app already in a live session is refused with 409 and "code": "version_downgrade" — Android would refuse it too, but later and less clearly. Re-send with replace=true (multipart field on upload/attach, {"replace": true} on attach-recent, --replace on the CLI, replace on the MCP push tool) to uninstall the installed app first and then install the older build; the response says plainly that the uninstalled app's data was deleted. First installs in a fresh session are unaffected.
Device actions
All actions are POST /portal/api/device/<device_id>/<action> with a JSON body (or empty). Unknown device → 404; unknown action → 404.
| Action | Body | Does |
|---|---|---|
run | {"driver": "agent"|"user"} (optional) | Starts a session. Default driver: agent for API callers, user for the portal. Returns {"ok": true, "status": "starting", "driver": …}. Poll state until live (~30–60s). 409 if already running; 400 if no app is attached. |
stop | — | Stops the session. The session link dies immediately; the driver resets to user. |
input | see below | Sends input to the live session. Driver-gated (below). 409 if not running. |
handoff | {"driver": "agent"|"user", "note": "…"} | Passes the wheel mid-session. 409 if not running; 400 for an unknown driver value. When the human hands the wheel back to the agent, an optional note (max ~300 chars) rides along: it is delivered to the agent exactly once — on its next state read or input result, as handback_note with resumed: true — and the handback clears any pending request-human. |
request-human | {"reason": "…"} | The driving agent asks the human for a step only a human should do (a password or 2FA code typed on the phone, a payment approval, a CAPTCHA). Records the request on the session — the portal's device card shows "Your agent needs you: <reason>" — and answers {ok, handoff, portal_url, message_for_human}: a ready-to-send message for the human, which the agent relays verbatim. A second request updates the pending one's reason. 409 if no session is live; if the human already holds the wheel, the answer is 200 with already_user: true instead of an error. Driver-class: readonly keys are refused. |
finish | {"summary": "…", "verdict": "passed"|"failed"|"mixed", "end_session": false} | The driving side files the session wrap-up. The answer composes the human's message from the server's own counts (session duration, actions taken, error count — errors are counted by the engine whenever a driving call returns an error; act batches count their failed actions), the app identity, the verdict, the agent's summary, and recording availability, ending with the portal link. The report is stored on the session (finish_report in state) and sealed into the session archive. end_session defaults to false — finishing does not stop the phone; with true the session stops after composing and the wording switches to the session-record form. 409 if no session is live; 400 for a missing summary or unknown verdict. Driver-class: readonly keys are refused, and only the side holding the wheel files it. |
links | — | Returns {"ok": true, "status", "driver", "driver_changed_at", "driver_note", "handoff", "finish_report", "session_url"}. session_url is null unless live. |
attach | multipart, file field apk | Same as upload, addressed to this device. On a running device (driver-gated) the app is hot-swapped in live; 409 while starting. |
attach-recent | {"fid": "…"} | Re-attaches a build from your recents (fids come from state). On a running device (driver-gated) the build is hot-swapped in live. 404 for an unknown fid. |
remove-apk | — | Detaches the app. Device must be off. |
settings | see below | Name, model/version. |
Settings body
| Key | Effect |
|---|---|
name | Rename the device (max 40 chars) |
profile | pixel | galaxy — device must be off (409); unknown value → 400 |
android | "10"–"15" — device must be off (409); unknown value → 400 |
Input
POST /portal/api/device/<id>/input. Coordinates are fractions of the screen (0–1), so they survive both device profiles. Values outside 0–1 — pixel coordinates are the classic case — are rejected with a 400 that explains the system; they are never silently clamped into a corner.
| Body | Does |
|---|---|
{"action": "tap", "x": 0.5, "y": 0.5} | Tap |
{"action": "swipe", "x1": …, "y1": …, "x2": …, "y2": …, "dur": 300} | Swipe (dur = duration in ms, optional) |
{"action": "key", "key": 66} | Android keyevent code (3 = Home, 4 = Back, 66 = Enter, 67 = Delete) |
{"action": "text", "text": "hello"} | Types text into the focused field (max 500 chars). Fails with 400 when no input is focused — nothing is typed into the void. |
{"action": "launch"} | Bring the app back to the foreground — after Back exits to the launcher, or the app crashed or was closed. About 2 seconds; no reboot. |
{"action": "observe"} | A compact numbered snapshot of the screen: snapshot_id, foreground app, keyboard/focus state, screen size, and the useful elements (ref, type, text, desc, id, bounds, center, state flags) in reading order — built for a model's context window, much cheaper than the full tree. |
{"action": "tap_ref", "snapshot_id": "…", "ref": 3} | Tap the element a snapshot numbered. The server re-finds the element on the current screen first; if it moved or changed, the call fails 409 with "code": "stale_snapshot" instead of tapping whatever sits there now. Observe again and retry with the new ref. |
{"action": "wait_for", "text": "Welcome", "present": true, "timeout_s": 30} | Poll until an element (text, desc, or id) is present — or gone, with "present": false. Answers {"found": bool, "waited_ms": n}; found: false at the timeout is a result, not an error. |
{"action": "act", "actions": […], "observe": "ui"} | Run a batch of up to 20 actions in order: tap (fractions, or target by text/id/desc or snapshot ref), swipe, long_press, double_tap, type (clear, submit), key, open_app, close_app, open_url, notifications, wait, wait_for. Stops at the first failure (earlier actions stay done), returns per-action results with timing and error envelopes, and never auto-replays. observe: none | ui | screenshot | both attaches the screen after the batch. |
{"action": "record_start", "name": "…"} | Start recording the screen (Android screenrecord; one recording caps at 180s). Answers {"ok": true, "rec_id": "rec_…", "started_at": …}; 409 already_recording if one is already running. |
{"action": "record_stop"} | Stop the recording and pull the MP4 to the server. Answers the recording entry: {ok, rec_id, size, duration_s, started_at, stopped_at}. Download it via GET …/recording/<rec_id> (below). A recording in progress is stopped and saved automatically when the session ends or the phone reboots. |
{"action": "record_status"} | {ok, recording: bool, rec_id?, elapsed_s?, last: <latest finished recording entry>}. If screenrecord ended on its own (the 180s cap), status finalizes and indexes it first. |
{"action": "device", "op": "…", …} | Device controls — see the table under Device controls below. Driver-gated like all input. |
Device controls ({"action": "device", "op": …})
| Op | Body | Does |
|---|---|---|
timezone | {"tz": "America/New_York"} | Set the device timezone (IANA name, validated). The answer is verified against the device — the reply carries the timezone the device actually reports. |
locale | {"locale": "de-DE", "package": "…"} | Set an app's language (BCP 47 tag) — the Android per-app locale. package defaults to the session's app. Verified on the device. The app may need a relaunch to pick it up. There is no system-wide locale switch in this API. |
location | {"lat": 40.7128, "lon": -74.0060} | GPS reports this fix (a mock test provider). Stays until location_clear or the session ends. Android keeps the last-known cached fix readable after a clear, until a real fix replaces it. |
location_clear | — | Remove the mock GPS provider. |
file_push | {"path": "Download/fixture.json", "data_b64": "…"} | Write a file into shared storage (max 10 MB). Paths are confined to /sdcard — .. and absolute paths outside it are refused. |
file_list | {"path": "Download"} | List a folder in shared storage: {entries: [{name, dir, size}]}. |
file_delete | {"path": "Download/fixture.json"} | Delete a file from shared storage (verified gone). |
reboot | — | Restart the phone. Same phone, same data — app, files, and settings survive; the app is relaunched to the foreground. Takes under a minute; the stream takes a few seconds to come back. (Restart only through this op — it's the safe restart; the phone comes back with everything in place.) |
No clipboard control: not available on these phones.
Driver gating
Input is accepted only from the side that holds the wheel: an API caller needs driver == "agent"; the portal needs driver == "user". The refusal is a 403 that says exactly what's wrong. When the owner takes the wheel in the portal, the refusal carries "code": "DRIVER_TAKEN_BY_USER" plus the driver_note (when it happened) — so an interrupted agent can report exactly where its run stopped instead of failing mysteriously. Any input also resets the idle timer.
Recordings & file downloads
GET /portal/api/device/<id>/recording/<rec_id>
Download a finished recording's MP4 (video/mp4). The recording's session must be this device's current session or one of its archived sessions — so recordings stay downloadable after the session ends. Supports Range requests (206 + Content-Range), so a cut transfer resumes where it stopped; the CLI's record pull and the MCP download tool use exactly that. Unknown recording → 404; file rotated off the server → 410.
GET /portal/api/device/<id>/file?path=Download/fixture.json
Pull a file (max 10 MB) from the device's shared storage. Follows the driver gate (it reads device storage, not just the screen). Range requests resume like the recording download. Missing file → 404 not_found; over the cap → 413 file_too_large.
Screenshots
GET /portal/api/device/<id>/screenshot
Returns the current screen as image/png (same auth) — the device's real screen (a PNG capture), so the picture shows exactly what the hierarchy describes. 409 if the device isn't running. Capture is retried internally; a 503 (with "retryable": true) means the frame is genuinely unavailable right now — retry after a few seconds. The X-Captured-At header carries the capture time. (…/frame is the same image, used by the portal.)
UI hierarchy
GET /portal/api/device/<id>/hierarchy
The current screen as a pruned element tree instead of pixels — from Android's own view hierarchy. Each node carries what an agent needs to act without guessing: class, text, desc (content-description), id (resource id), pixel bounds, fractional center, and state flags (click, scroll, edit, check/checked, selected, focused, enabled). Pure layout containers are spliced out; the tree caps at 500 elements (truncated says if it hit the cap).
{"ok": true, "width": 736, "height": 1600, "package": "com.InkSync",
"tree": {"class": "FrameLayout", "children": [
{"class": "Button", "desc": "Sign up later", "bounds": [24, 1362, 712, 1436],
"center": [0.5, 0.874], "click": true}]}}
Like a screenshot, the hierarchy is observation: either caller may fetch it regardless of driver mode. Two rules of thumb: navigate and assert with the tree (fast, cheap, exact), verify visual correctness — clipping, overlap, images — with a screenshot. On Flutter and other semantics-driven UIs, labels arrive in desc rather than text; selector matching in the CLI and MCP searches both.
Debug logs
GET /portal/api/device/<id>/logs
The attached app's log stream (Android logcat) for this session — its prints, warnings, errors, and crash traces, captured from session start and filtered to the app's process (plus the system crash buffer when the app dies). Query parameters: lines (default 200, max 2000), since (seconds back), level (minimum level V/D/I/W/E/F — E means errors and crashes only), q (substring), after (a line seq — for polling/following). Response: {ok, lines:[{seq, t, raw}], latest, count}. After the session ends, the last 500 lines stay available from the saved tail (the response gains "ended": true) until the next session replaces them. Observation, like screenshots: either caller, any driver mode.
GET /portal/api/device/<id>/logs/sessions
The device's archived session logs: {ok, sessions: [{sid, started, ended, duration_seconds, action_count, end_reason, app_label, package, driver, lines, live}]} — one entry per past session, so a crash from an earlier run is still reachable after a new session starts. Ended entries carry the session's duration_seconds, the number of input actions executed (action_count — individual actions plus each action inside a batch), and how it ended (end_reason: stop, idle, orphan, error, or unknown where the end wasn't observed).
GET /portal/api/device/<id>/logs/session/<sid>
One session's log lines (the live session included). Same query parameters and line shape as …/logs; poll the live session with after=<seq> to follow it.
Public link endpoints (no auth — the token is the credential)
| Endpoint | Behavior |
|---|---|
GET /v/<token> | The view-only session page. Valid only while that session lives. |
GET /v/<token>/status | {"live": bool}. |
GET /i/<token> | An install link minted from the owner's portal ("Install on my phone"): serves the APK to the owner's own phone. Valid 15 minutes from minting. Share pages never serve files. |
Account, keys & setup
POST /portal/api/devices
Create a device in a free slot (either caller). Body: {"name": "…", "profile": "pixel"|"galaxy", "android": "15"} — name optional (defaults to the profile's name), profile and Android default to pixel / "15". Unknown profile or version → 400; no free slot → 403. Answers {"ok": true, "device": {…}}.
POST /portal/api/keys · POST /portal/api/keys/revoke
API-key management — portal session, or an admin-scope API key (an agent or readonly key cannot create or revoke keys: 403). Create takes {"name": "…", "scope": "agent"|"readonly"|"admin", "expires_days": 30|90|365} — scope defaults to agent, expiry to never — and returns the key, which is also saved to the account's key list in the portal (viewable there any time). Revoke takes {"id": <key id>}; unknown or already-revoked id → 404. Keys are listed in GET /portal/api/state for human callers, each with its scope, expiry, and last-used time.
POST /portal/api/privacy
Set the account's diagnostic-sharing preference — portal session only. Body: {"share_diagnostics": true|false}. When off, the feedback endpoint refuses reports with 403 and clients stop inviting them.
POST /portal/api/device/<id>/install-link
Mint a 15-minute "Install on my phone" link — portal session only. Body: {"src": "dev"|"fid"} (the device's attached app, or a recent upload by fid). Answers the token, the /i/<token> URL, and a QR SVG. This is the only file-serving path in the product, and it exists for the owner's own phone — share pages never serve files.
Agent setup grants — POST /portal/api/setup/start · POST /portal/api/setup/token
The device-code style onboarding behind previewapk setup (full walkthrough: /setup.md). start (no auth; optional {"label": "…"}) creates a grant and returns {"code": "XXXX-XXXX", "url": "…/portal/agent-grant", "expires_in": 600} — rate-limited per IP. A signed-in human approves the code on the grant page; approval mints a normal account API key (named setup — <date>). token polls by code and answers only {"status": "pending"|"denied"|"expired"|"claimed"} — except the first poll after approval, which returns the key exactly once. Grants require an existing account: no signup bypass, no Terms bypass.
Errors
Every error is the human error string plus the envelope fields where the endpoint provides them: a stable code (branch on it — messages may change), retryable, a concrete next step, and a request_id that also arrives as the X-Request-Id response header. Ambiguous element selectors return their candidates instead of guessing. An error whose outcome is unknown is never marked retryable.
| Status | Means | What to do |
|---|---|---|
400 | Bad request — not an APK, unknown profile/version/driver, unknown input action | Fix the request; the error field says which |
401 | Missing or wrong API key | Check the X-Api-Key header |
403 | Driver gating — the other side holds the wheel | handoff, or start the session in your mode |
401 key_expired | The API key passed its expiry | Create a new key (portal, or an admin key) |
403 key_readonly | A readonly key tried to drive or change something | Observe with this key; drive with an agent-scope key |
409 version_downgrade | The upload is older than the app in the live session | Upload a newer build, or re-send with replace=true (uninstalls first; its data is deleted) |
502 install_version_mismatch | The installed package's versionCode isn't the uploaded APK's | Check the build you uploaded; the envelope's details carry both values |
404 | Unknown device, action, fid, or link token | Re-read state; re-check the URL |
409 | State conflict — already running, not running, or device busy for a change that needs it off | Poll state; stop first if needed |
502/503 | Device engine hiccup or a screenshot grabbed mid-warm-up | Retry with a few seconds' backoff |
Polling: after run, poll GET /portal/api/state every 3–5 seconds until the device is live (typically 30–60s). The API is built for one account's devices, not bulk traffic — a poll every few seconds is plenty; there is no benefit to hammering it.