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):

ScopeCan do
agent (default)Everything a key could do before scopes existed: observe, drive, upload, manage devices.
readonlyObserve 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".
adminEverything 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

FieldMeaning
id, nameDevice id (e.g. dev1) and its display name
profilepixel (Pixel 9 Pro, 712×1600) or galaxy (Galaxy S24, 736×1600)
androidAndroid version string, "10"–"15"
statusoff · starting · live · error
driveruser or agent — who holds the wheel in the current (or next) session
apknull, 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_noteWhen 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.
handoffnull, 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_reportnull, 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, resumedAgent 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_urlSession link while live, else null
watchingCurrent viewer count on the session link
idle_leftSeconds until idle auto-stop while live, else null
errorLast 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.

ActionBodyDoes
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.
inputsee belowSends 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.
attachmultipart, file field apkSame 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.
settingssee belowName, model/version.

Settings body

KeyEffect
nameRename the device (max 40 chars)
profilepixel | 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.

BodyDoes
{"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": …})

OpBodyDoes
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)

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

StatusMeansWhat to do
400Bad request — not an APK, unknown profile/version/driver, unknown input actionFix the request; the error field says which
401Missing or wrong API keyCheck the X-Api-Key header
403Driver gating — the other side holds the wheelhandoff, or start the session in your mode
401 key_expiredThe API key passed its expiryCreate a new key (portal, or an admin key)
403 key_readonlyA readonly key tried to drive or change somethingObserve with this key; drive with an agent-scope key
409 version_downgradeThe upload is older than the app in the live sessionUpload a newer build, or re-send with replace=true (uninstalls first; its data is deleted)
502 install_version_mismatchThe installed package's versionCode isn't the uploaded APK'sCheck the build you uploaded; the envelope's details carry both values
404Unknown device, action, fid, or link tokenRe-read state; re-check the URL
409State conflict — already running, not running, or device busy for a change that needs it offPoll state; stop first if needed
502/503Device engine hiccup or a screenshot grabbed mid-warm-upRetry 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.