HTTP API

The local API that the MobAI app and mobai serve answer on this computer. Drive iPhones, Android phones, simulators and emulators from any language that can send an HTTP request.

Basics

The MobAI app, and a host started with mobai serve, answer on http://127.0.0.1:8686/api/v1. Every path on this page goes after that. The port can be changed in the app's API server settings.

{id} is the device id from GET /devices: the UDID of an iPhone or simulator, the serial of an Android phone or emulator. Bodies are JSON unless a route says otherwise.

By default the API only answers this computer. Turn on Allow external connections in the API server settings to reach it from other computers. With an access token set there, calls from other computers must send it as X-API-Key: <token> or Authorization: Bearer <token>. Calls from this computer never need it, and neither do /health, /version, /openapi.json and /docs.

To share devices between several scripts or agents, claim a device first. See Device Claims.

Errors

A failed call answers with a status of 400 or above and a JSON body. code and details are there only when a route sets them.

{"error": "device not found: emulator-5554", "code": "...", "details": "..."}
StatusMeaning
400The body or a parameter is wrong
402The plan does not cover it. code is device_limit_reached (Free drives one device at a time) or pro_required
403A safety rule in the app blocked the action
404No such device, app or resource
409The device is claimed by someone else, or a claim is required
500The device or the bridge failed. error says why

Devices

Every device MobAI can drive: phones and tablets on USB or the network, simulators and emulators on this computer, devices shared by other MobAI computers, and cloud farm sessions. A device id is the serial number (Android) or the UDID (iOS). Devices attached to another MobAI computer have ids that start with remote:.

GET /devices List devices

Returns an array of every device MobAI sees. Devices on another MobAI computer that someone else is using are left out, since every action on them would be refused.

Response

[{
  "id": "00008130-001A2C3E0A1B401E",
  "name": "iPhone 15 Pro",
  "platform": "ios",
  "model": "iPhone16,1",
  "osVersion": "18.2",
  "bridgeRunning": true,
  "virtual": false,
  "transport": "USB",
  "connectionState": "ready",
  "controlUrl": "http://127.0.0.1:8787/device/00008130-001A2C3E0A1B401E?server=http%3A%2F%2F127.0.0.1%3A8686",
  "screenWidth": 393,
  "screenHeight": 852
}]
FieldTypeDescription
idstringSerial number (Android) or UDID (iOS). Use it in every /devices/{id} route.
namestringDevice name
platformstringandroid or ios
modelstringHardware model
osVersionstringOS version
bridgeRunningboolTrue when the bridge is up and the device takes actions
virtualboolTrue for simulators and emulators
inUseboolPresent while someone holds a claim on the device
inUseBystringThe holder label of that claim, when it has one
remoteboolTrue for a device attached to another MobAI computer. Tools on this computer cannot reach it directly.
nodestringName of that other computer
cloudboolTrue for a cloud device farm session
transportstringHow an iPhone is connected: USB, Network or Tailscale. Other devices may name their own.
connectionStatestringready when transport is known
controlUrlstringA page on this computer for watching and controlling the device in a browser
screenWidth, screenHeightintegerScreen size in action coordinates (points on iOS, pixels on Android). Only while the bridge is running.

Note: inUse and the fields after it are left out when false or empty.

GET /devices/{id} Get one device

Returns one device as the same object GET /devices lists. An unknown id answers 404.

{"error": "device not found"}

Device Claims

A claim reserves a device so two scripts, agents or CI jobs do not drive it at the same time. Claiming returns a lease token. Send it as the X-Lease-Token header on every call for that device. Each call that carries the token renews the claim for its full lifetime, so a claim only runs out when nothing is done with the device.

POST http://127.0.0.1:8686/api/v1/devices/{id}/tap
X-Lease-Token: q3J8Zk1mW0xYb2VnT3pQdkR4c1N5aGJm

{"x": 195, "y": 422}
CallerClaim needed?
This computerNo, unless Require device claim is on in the API server settings. A token sent anyway still renews its claim.
This computer, Require device claim onYes, for every device. Without one the call answers 409 CLAIM_REQUIRED, or 409 DEVICE_IN_USE when someone else holds it. The MobAI window itself is never blocked. GET /health reports the setting as requireDeviceClaim.
Another computerAlways. A device with no claim and no bridge answers 409 CLAIM_REQUIRED. A device someone started on this computer without a claim answers 409 DEVICE_IN_USE, and cannot be claimed from elsewhere.

Routes that read from or act on a device check the claim. GET /devices and GET /devices/{id} do not. If an access token is set in the API server settings, calls from other computers must also send it as X-API-Key or Authorization: Bearer.

How long a claim lasts: from another computer, 30 minutes by default (ttlSeconds changes it). From this computer, the lease idle timeout in the API server settings, 10 minutes by default. A longer ttlSeconds is cut down to it. An expired claim is freed within 30 seconds. When a claim from another computer expires, MobAI also stops the bridge it left running. Stopping the bridge with POST /devices/{id}/bridge/stop ends any claim on the device.

POST /devices/claim Claim a device

Claims the named device, or any free one. Every field is optional. An empty body claims any free device. From this computer, a claim without device only picks devices attached to this computer, never remote or cloud ones.

FieldTypeDescription
devicestringDevice id, or device name (case does not matter). Leave out for any free device.
ttlSecondsintegerClaim lifetime without activity. Default 1800 from another computer, the idle timeout setting from this one.
holderstringA label for who holds it, shown as inUseBy and in conflict errors
clientIdstringA stable id of your own that becomes the lease token. Claiming again with the same clientId, for example after your process restarts, gets your claim back instead of a 409.
ownerstringGroups your claims across devices. A claim for any device skips the ones this owner already holds. From this computer, a second claim on a device with the same owner gets the same lease.
{"device": "emulator-5554", "holder": "ci-runner-3"}

Response

{
  "deviceId": "emulator-5554",
  "leaseToken": "q3J8Zk1mW0xYb2VnT3pQdkR4c1N5aGJm",
  "expiresAt": "2026-11-05T10:40:00Z",
  "device": {"id": "emulator-5554", "name": "Pixel 8", "platform": "android", ...}
}
StatusCodeMeaning
404No device matches device
409DEVICE_IN_USESomeone else holds the device, or every matching device. The error names the holder when the claim has one.
409No devices are connected yet, or a claim for any device from this computer found only remote and cloud devices

Note: Every failed claim answers 409 so a caller can poll until a device frees up.

POST /devices/release Release a claim

Frees the device held by the token. 400 without a token, 404 when no claim matches it.

{"leaseToken": "q3J8Zk1mW0xYb2VnT3pQdkR4c1N5aGJm"}

Response

{"status": "released"}

Note: A device the user gave to an App Builder agent session stays claimed until the user frees it. Releasing it answers {"status": "kept", "message": "..."} and renews the claim.

POST /devices/renew Renew a claim

Extends a claim by its full lifetime without touching the device. Calls that send X-Lease-Token already do this, so you only need it while the device sits idle. 404 means the claim expired or never existed: claim again.

{"leaseToken": "q3J8Zk1mW0xYb2VnT3pQdkR4c1N5aGJm"}

Response

{"expiresAt": "2026-11-05T11:10:00Z"}

Screenshots & UI Tree

GET /devices/{id}/screenshot Take a screenshot

Captures the screen and saves it to a file on this computer, then returns the path. Add ?format=base64 to get the image in the response instead.

ParameterTypeDescription
formatstringbase64 returns the image as data and saves no file
pathstringFolder to save into. ~/ works. Default: mobai/screenshots in the system temp folder.
namestringFile name. The extension is added when missing. Default: <device id>-<unix time>.
low_qualitybooltrue returns a smaller image sized for AI models, usually JPEG. See scale below.

Response

// Default
{"path": "/tmp/mobai/screenshots/emulator-5554-1762337400.png", "format": "png"}

// ?format=base64
{"data": "iVBORw0KGgoAAAANSUhEUgAA...", "format": "png"}

// ?low_quality=true, when the image had to be shrunk
{"path": "/tmp/mobai/screenshots/emulator-5554-1762337400.jpeg", "format": "jpeg", "scale": 2, "tip": "Screenshot downscaled 2x. ..."}

Note: A low_quality image matches action coordinates unless scale is present. Then multiply any x and y you read from the image by scale before tapping. UI tree coordinates never need this.

GET /devices/{id}/ui-tree Get the UI tree

Returns the elements on screen as text, one line each: [index] Type "text" #accessibilityId [state] (x,y widthxheight). Child elements are indented. The index is what tap and the other actions take as index, and the bounds are in action coordinates.

ParameterTypeDescription
onlyVisibleboolOnly elements on screen (default: true)
includeKeyboardboolInclude the on-screen keyboard's keys (default: false)
verboseboolAlso return an elements array with every field (default: false)
compactboolDrop coordinates and indentation from the text to save tokens (default: false)
textRegexstringRegular expression matched against each element's text, or its value, then its content description, when the text is empty. Only matching elements are returned. An invalid expression answers 400.
boundsXintegerLeft edge of a rectangle. Only elements fully inside it are returned. Use with boundsY, boundsW, boundsH.
boundsYintegerTop edge of the rectangle
boundsWintegerWidth of the rectangle
boundsHintegerHeight of the rectangle

Response

{
  "tree": "[0] Application \"Settings\" (0,0 393x852)\n  [6] Switch \"Airplane Mode\" [off] (16,180 361x44)\n  [7] Button \"Wi-Fi\" #wifi-cell (16,224 361x44)\n",
  "activity": ""
}

With verbose=true, trimmed

"elements": [{
  "index": 6, "parentIndex": 2, "depth": 2,
  "type": "android.widget.Switch", "text": "Airplane mode", "value": "0",
  "clickable": true, "enabled": true,
  "x": 32, "y": 448, "width": 1016, "height": 132
}]

Each element can also carry contentDesc, accessibilityId, editable, scrollable, focused, selected, hittable, possiblyOccluded (something may cover it) and clipped (how many points or pixels a scrolling parent cuts off each edge: top, bottom, left, right). Flags that are false are left out.

Note: A tree over 30,000 characters keeps only buttons, switches and text fields and starts with a warning line. On iOS the text read from the screen is added after it. activity is usually empty: use GET /devices/{id}/state for the app in front.

GET /devices/{id}/ocr Read text and icons on screen (iOS)

Reads the text on screen and names common icons. Useful for system sheets that show no elements in the UI tree. iOS only (iPhones and simulators). Android devices answer 400.

Response

{
  "elements": [{"text": "Sign In", "confidence": 0.98, "x": 150, "y": 400, "width": 100, "height": 30, "centerX": 200, "centerY": 415}],
  "icons": [{"x": 345, "y": 58, "width": 24, "height": 24, "centerX": 357, "centerY": 70, "distance": 0.11,
             "labels": [{"label": "share", "symbol": "square.and.arrow.up", "distance": 0.11}]}],
  "imageSize": {"width": 1179, "height": 2556},
  "screenSize": {"width": 393, "height": 852},
  "scale": 3.0
}

Note: Tap centerX, centerY. They are in screen points like every other action. Each icon lists candidate names, nearest first. icons is left out when none were found.

Actions

Action routes start the bridge when it is not running, so the first call can take longer. An iPhone may need its bridge built or signed first. Coordinates are in the same space as the UI tree: points on iOS, pixels on Android. On success most routes answer {"success": true}. Errors come back as {"error": "...", "code": "..."}.

StatusMeaning
400Bad body or a missing field
402Plan limit. device_limit_reached: the Free plan drives one device at a time. pro_required: the device needs Pro.
403The device's Safety Settings block the action
404No device with that id
409A claim is needed or someone else holds the device. See Device Claims.
503The iPhone's bridge could not be prepared
500The device reported an error. The message says what went wrong.
POST /devices/{id}/tap Tap

Taps an element by its UI tree index, a point, or a point given as a percentage of the screen. Send one of the three. When several are sent, index wins, then x/y.

FieldTypeDescription
indexintegerElement index from the UI tree. Taps its center. Out of range answers 400.
x, yintegerPoint to tap
xPercent, yPercentnumberPoint as 0 to 100 percent of the screen width and height
{"index": 7}
{"x": 195, "y": 422}
{"xPercent": 50, "yPercent": 90}

Response

{"success": true, "data": {"x": 196, "y": 246}}  // the point tapped
POST /devices/{id}/double-tap Double tap

Double taps. Same fields and response as /tap.

POST /devices/{id}/long-press Long press

Presses and holds. Same fields and response as /tap, plus duration: how long to hold in milliseconds (default: 500).

{"index": 7, "duration": 1200}
POST /devices/{id}/two-finger-tap Two-finger tap

Taps with two fingers at once, side by side around the point. Same fields and response as /tap. Works on iOS and Android.

POST /devices/{id}/pinch Pinch to zoom
FieldTypeDescription
x, yintegerCenter of the pinch
scalenumberRequired. Above 1.0 zooms in, below 1.0 zooms out.
velocitynumberScale change per second (default: 1.0)
{"x": 196, "y": 426, "scale": 2.0}

Response

{"success": true, "data": {"x": 196, "y": 426, "scale": 2}}
POST /devices/{id}/swipe Swipe

Swipes from one point to another. duration is in milliseconds (default: 300). All four coordinates at 0 answers 400, which usually means misspelled field names.

{"fromX": 200, "fromY": 600, "toX": 200, "toY": 200, "duration": 300}
POST /devices/{id}/drag Drag

Drags from one point to another, slower than a swipe. Use it to move icons, reorder rows or pull a slider.

FieldTypeDescription
fromX, fromY, toX, toYintegerStart and end points. All four at 0 answers 400.
durationintegerTime to move, in ms (default: 500)
pressDurationintegerHold before moving, in ms. Use it to pick up an item that needs a long press.
holdDurationintegerHold at the end before letting go, in ms. Stops a list from flinging on.
{"fromX": 100, "fromY": 200, "toX": 300, "toY": 400, "pressDuration": 800}
POST /devices/{id}/drag-path Drag along a path

Drags one finger through a list of points, for drawing, signatures or pattern locks. At least 2 points. The first point is where the finger goes down, and its duration_ms is an optional hold before moving. On every later point duration_ms is how long the move to it takes, and must be above 0.

{"points": [
  {"x": 100, "y": 400, "duration_ms": 0},
  {"x": 150, "y": 300, "duration_ms": 150},
  {"x": 300, "y": 500, "duration_ms": 300}
]}
POST /devices/{id}/scroll-until-visible Scroll to an element

Scrolls until an element whose text contains targetText is on screen, inside the list when you name one.

FieldTypeDescription
targetTextstringRequired. Text the element contains.
listIndexintegerUI tree index of the list to scroll inside. Leave out to scroll the screen.
directionstringdown (default), up, left or right
maxScrollsintegerHow long to keep scrolling, at about one scroll a second. Without it the search gives up after about 10 seconds.
{"targetText": "Privacy", "listIndex": 3}

Response

{
  "found": true,
  "element": {"index": 41, "type": "Cell", "text": "Privacy & Security", "x": 16, "y": 610, "width": 361, "height": 44},
  "uiTree": {"tree": "...", "elements": [...], "activity": ""}
}

// not found
{"found": false}
POST /devices/{id}/collect-list Collect every row of a list

Scrolls through a list and returns each element it saw once. It stops at the end of the list or at maxElements, and gives up after 5 minutes.

FieldTypeDescription
listIndexintegerUI tree index of the list
maxElementsintegerStop after this many (default: 0, no limit)
directionstringdown (default) or up

Response

{
  "elements": [
    {"index": 12, "type": "StaticText", "text": "Airplane Mode", "x": 60, "y": 190, "width": 120, "height": 22}
  ],
  "count": 1
}
POST /devices/{id}/type Type text

Types into the field that has focus. Tap the field first.

{"text": "Hello World"}
POST /devices/{id}/clear-text Clear the focused field

Empties the text field that has focus. No body. On iOS, a screen with no focused field answers 500.

POST /devices/{id}/press-key Press a hardware or system key

Presses a key by name. A name the platform does not have answers 400.

PlatformKeys
iOShome, volume_up, volume_down
Androidhome, back, recent_apps, enter, tab, delete, escape, volume_up, volume_down, mute, power, play_pause, next, previous
{"key": "back"}
POST /devices/{id}/dismiss-keyboard Hide the keyboard

Hides the on-screen keyboard. No body. On Android this presses Back, so with no keyboard open it goes back a screen.

POST /devices/{id}/go-home Go to the home screen

Goes to the home screen. No body.

POST /devices/{id}/open-url Open a URL or deep link

Opens a web address or a deep link on the device. url is required.

{"url": "https://example.com"}
{"url": "myapp://orders/42"}

Device State

GET /devices/{id}/state App in front and keyboard

Says which app is in front, whether the keyboard is up, and on Android which element has focus.

Response

// iOS
{"activity": "Settings (com.apple.Preferences)", "packageName": "com.apple.Preferences", "keyboardShown": false}

// Android
{
  "activity": "",
  "keyboardShown": true,
  "focusedElement": {
    "index": 7, "type": "android.widget.EditText", "text": "Search",
    "bounds": {"x": 42, "y": 180, "width": 996, "height": 126},
    "clickable": true, "editable": true, "focused": true, "enabled": true
  }
}
GET /devices/{id}/clipboard Read the clipboard

Returns the plain text on the device clipboard.

{"text": "123456"}
POST /devices/{id}/clipboard Set the clipboard

Puts plain text on the device clipboard, ready to paste.

{"text": "[email protected]"}
POST /devices/{id}/location Set a simulated GPS location

Makes the device report this location to apps. Both lat and lon at 0 answers 400.

{"lat": 40.7128, "lon": -74.0060}

Note: Android phones need Android 12 or later and answer 400 otherwise. Emulators, iPhones and iOS simulators work on any version.

DELETE /devices/{id}/location Stop simulating location

Returns the device to its real location. An Android emulator has no real location, so it moves to 0, 0. Same Android 12 limit as above.

POST /devices/{id}/fold Open or close a foldable

Sets the hinge of a foldable iOS simulator and returns once the device has settled on its new screen. Screenshots, taps, the UI tree and the screen size then follow the screen in use. Foldable iOS simulators only, so Mac only. Any other device answers 400.

FieldTypeDescription
statestringclosed, book (half open) or open
anglenumberHinge angle from 0 (closed) to 180 (flat). Wins over state when both are sent.
{"state": "open"}
{"angle": 90}

Apps

GET /devices/{id}/apps List installed apps

Lists the apps on the device. iOS apps that declare Siri activities, intents or shortcut actions also carry a siri object.

[
  {"bundleId": "com.apple.Preferences", "name": "Settings", "version": "1.0"},
  {"bundleId": "com.example.notes", "name": "Notes", "version": "2.4.1",
   "siri": {"activities": ["com.example.notes.new"], "intents": ["INCreateNoteIntent"], "shortcutActions": ["compose"]}}
]
POST /devices/{id}/install-app Install an app

Installs an .apk on Android, an .ipa on an iPhone, or an .app on a simulator. Pass a path on this computer as JSON, or upload the file as multipart/form-data in a field named app (up to 4 GB) when it lives on another machine.

FieldTypeDescription
pathstringRequired. File on this computer.
resignbooliPhones only. Sign the IPA for this iPhone with an Apple ID before installing.
appleIdstringApple ID to sign with
passwordstringApple ID password. Leave out to use the saved login.
profilePath, certPath, keyPathstringFiles from an earlier signed install. While the profile is still valid for this iPhone, MobAI signs with them on this computer without asking Apple, and falls back to the Apple ID otherwise.
{"path": "/Users/me/builds/app-debug.apk"}

// iPhone, signed with an Apple ID first
{"path": "/Users/me/builds/App.ipa", "resign": true, "appleId": "[email protected]"}

// Upload the file instead of passing a path
curl -F "[email protected]" http://127.0.0.1:8686/api/v1/devices/{id}/install-app

Response

// With resign: where the signed IPA and signing files went. Pass the
// three paths back next time to skip Apple.
{
  "success": true,
  "data": {
    "bundleId": "com.example.app",
    "signedIpa": "/Users/me/builds/App-signed.ipa",
    "teamId": "A1B2C3D4E5",
    "profilePath": "/Users/me/builds/...",
    "certPath": "/Users/me/builds/...",
    "keyPath": "/Users/me/builds/..."
  }
}

Without resign the answer is {"success": true, "data": {}}, or {"success": true} for an upload. Signing can ask for a two-factor code in the MobAI window and may take up to 10 minutes.

Cloud devices: the build goes to the farm's app storage instead of a device, and the answer is {"success": true, "installed": false, "appRef": "...", "message": "..."}. Start the bridge with {"app": "<appRef>"} to run a session with it. resign is refused there.

POST /devices/{id}/launch-app Launch an app

Opens an app by bundle id (iOS) or package name (Android).

FieldTypeDescription
bundleIdstringRequired. Bundle id or package name.
argumentsstring[]For this launch only. Launch arguments on iOS, intent extras on Android (for example ["--ez", "UITesting", "true"]).
environmentobjectEnvironment variables for this launch only. iOS only. Android answers with an error.
withDebuggerbooliPhones only. Starts the app under a debugger, which Flutter debug builds need to run on an iPhone. Other platforms answer 400 or 500.
noWaitboolReturn as soon as the process starts. Simulators on this computer only, ignored elsewhere.
{"bundleId": "com.example.app", "arguments": ["-resetState"], "environment": {"API_URL": "http://localhost:3000"}}

Note: On Android and simulators this route starts the bridge, like the action routes. An iPhone launches without it, and so do a launch with withDebugger and a simulator launch with noWait.

POST /devices/{id}/kill-app Force-quit an app

Force-quits a running app. bundleId is required.

{"bundleId": "com.example.app"}
DELETE /devices/{id}/apps/{bundleId} Uninstall an app

Removes the app and its data from the device. A failure answers 500 with the device's reason.

Bridge Management

The bridge is the MobAI runner installed on the device. Action routes start it on their own. Use these routes to start it ahead of time, or to stop it and free the device.

POST /devices/{id}/bridge/start Start the bridge

Installs the bridge if needed and starts it. Answers at once when it is already running. An iPhone's first start can take a few minutes while its bridge is built or signed (5 minute limit). The body is optional and only used by cloud farm devices.

FieldTypeDescription
appstringCloud devices: the app to test, as the farm's reference (from install-app)
runOptionsobjectCloud devices: string options passed to the farm session, over the saved run settings

Response

{"success": true}

Note: On the Free plan a second device answers 402 device_limit_reached while another one's bridge is running.

POST /devices/{id}/bridge/stop Stop the bridge

Stops the bridge and ends any claim on the device, so the next caller can take it. Answers {"success": true}.

AI Agent

POST /devices/{id}/agent/run Run an AI agent task

Gives a task in plain words to MobAI's agent, which works the device until it is done. The call returns when the agent finishes. It uses the provider, model and keys from the app's AI settings.

FieldTypeDescription
taskstringRequired. What to do.
agentTypestringWhich agent runs it. Default: the one picked in the app, else toolagent.
useVisionbooltrue lets the agent look at screenshots. false keeps the app setting.
agentTypeNeeds
toolagentAn LLM provider key. Fast, best for simple tasks.
hierarchicalAn LLM provider key. A manager plans, a runner acts.
classicAn LLM provider key. Plans each step carefully.
claude-codeClaude Code installed on this computer. An LLM provider key must also be set in the app.
jevA TypeSafe key
clefClef settings (Cloudflare or self-hosted)
lunaAn OpenAI key
{"task": "Open Settings and turn on Airplane Mode", "agentType": "toolagent"}

Response

{"success": true, "result": "Airplane Mode is on."}

Note: 400 without task. 412 when the key the agent needs is not set in the app. Agents the app offers that are not in this table cannot run through this route, so pass agentType when the app's pick is one of them. success is the agent's own verdict: a task it could not finish still answers 200 with "success": false.

DSL Scripts

Run many steps in one call: taps by text, waits, assertions, retries. The step format is in the DSL reference. A script runs for at most 5 minutes.

POST /devices/{id}/dsl/execute Run a DSL script

Runs the steps in order and returns a result for each. For a device on another MobAI computer the whole script runs there, and files it makes come back to this computer.

FieldTypeDescription
stepsarrayRequired. The steps to run.
versionstring"0.2". Accepted but not checked.
on_failobjectWhat to do when a step fails: strategy (abort, skip, retry, replan, require_user), max_retries, retry_delay_ms, fallback_strategy, message
defaultsobjecttimeout_ms for steps that set none (default 120000), context (native or web)
modestringexplore (default): only the last observe step runs in full, and a failed step adds a debug block with the UI tree and a screenshot. deterministic: every observe step runs, and there is no debug block.
alertsstringaccept or dismiss system alerts (permission prompts, crash dialogs) that block a step. Default: leave them.
settleboolWait up to 500 ms for a moving target to stop before tapping it
paramsobjectNamed values with their defaults, used in steps as ${name}
{
  "version": "0.2",
  "steps": [
    {"action": "open_app", "bundle_id": "com.android.settings"},
    {"action": "tap", "predicate": {"text_contains": "Network"}}
  ],
  "on_fail": {"strategy": "abort"}
}

Response

{
  "success": true,
  "step_results": [
    {"success": true, "step_index": 0, "action": "open_app", "started_at_ms": 1762337400120, "duration_ms": 812},
    {"success": true, "step_index": 1, "action": "tap", "started_at_ms": 1762337400932, "duration_ms": 240,
     "result": {"matched_element": {"index": 12, "type": "android.widget.TextView", "text": "Network & internet",
                 "bounds": {"x": 189, "y": 640, "width": 402, "height": 59}}}}
  ],
  "total_time_ms": 1052,
  "metadata": {"interactive_elements": {"buttons": 4, "switches": 0, "text_fields": 1}, ...}
}

A failed step still answers 200, with "success": false on the script and an error on the step (code, message, and when useful candidates and hint). Screenshots from observe steps and debug blocks are saved as files, and the result holds their paths with "screenshot_saved": true. started_at_ms is a Unix time in milliseconds.

Note: A body that is not valid JSON answers 400, and a script that cannot run at all answers 500. Both look like {"code": "EXECUTION_ERROR", "message": "..."}.

Web Automation

Read and drive web pages on the device. On an iPhone this covers Safari tabs and web views inside apps. On Android it covers Chrome tabs. Simulators and farm devices answer 400. On an iPhone, turn on Web Inspector in Safari's Advanced settings first. Call GET /web/pages before any other web route: it connects to the browser and selects a page, and the other routes fail until it has run.

GET /devices/{id}/web/pages List pages and select one

Lists the open pages and selects the first one that answers. That page has isActive: true. On Android, id is the page's place in the list, so call this again after tabs open or close.

Response

[{
  "id": 1,
  "title": "Example Domain",
  "url": "https://example.com/",
  "appId": "412",
  "isActive": true
}]
POST /devices/{id}/web/select Select a page

Makes another page the target of the web routes. Answers 400 when the page is not open on the device.

{"pageId": 1}
// Response
{"success": true}
GET /devices/{id}/web/dom Get the page HTML

Returns the selected page's HTML as it is now, scripts' changes included.

{"dom": "<html><head>...</head><body>...</body></html>"}
POST /devices/{id}/web/navigate Open a URL in the page

Loads a URL in the selected page. url is required.

{"url": "https://example.com"}
// Response
{"success": true}
POST /devices/{id}/web/click Click by CSS selector

Clicks the first element that matches a CSS selector. Fails when nothing matches.

{"selector": "button.submit"}
POST /devices/{id}/web/type Fill a field by CSS selector

Focuses the first input or textarea that matches, sets its value and fires input and change events, so React and Vue forms see the new value. It replaces the value in one go and does not press keys.

{"selector": "input#email", "text": "[email protected]"}
POST /devices/{id}/web/execute Run JavaScript

Runs JavaScript in the selected page and returns the value of the script as an expression. A top-level return is a syntax error, so write document.title, or wrap longer code in a function you call right away. A script error comes back with wasThrown: true and the message in error.

{"script": "Array.from(document.links).map(a => a.href)"}

// Response
{"value": ["https://www.iana.org/domains/example"], "wasThrown": false}

// Script error
{"value": null, "wasThrown": true, "error": "ReferenceError: Can't find variable: foo"}
DELETE /devices/{id}/web Close the web connection

Closes the connection to the browser. The next GET /web/pages opens a new one. Answers {"success": true}.

Port Forwarding

Reach a port on an iPhone or Android device from this computer. A simulator shares this computer's network, so there is nothing to forward: the answer's hostPort is the device port, and GET /forward lists nothing for it. Farm devices cannot forward.

POST /devices/{id}/forward Start a forward

Forwards a port on this computer to a port on the device. The forward stays until you stop it.

FieldTypeDescription
devicePortintegerPort on the device. Required.
hostPortintegerPort on this computer. Default 0: pick a free one.
{"devicePort": 8080}
// Response
{"hostPort": 49152, "devicePort": 8080, "id": "android-emulator-5554-49152-8080-1762337702000000000"}
POST /devices/{id}/forward/hold Forward while the request is open

Same body as POST /forward. Answers at once in plain text, then keeps the request open. The forward ends when you close the connection, so a script cannot leave one behind.

# Request
curl -N -X POST http://127.0.0.1:8686/api/v1/devices/{id}/forward/hold -d '{"devicePort": 8080}'

# Response (plain text, then the request stays open)
forwarded 8080 -> 49152
GET /devices/{id}/forward List forwards
[{"id": "android-emulator-5554-49152-8080-1762337702000000000", "hostPort": 49152, "devicePort": 8080}]
DELETE /devices/{id}/forward/{forwardId} Stop a forward

Stops the forward with the id that POST /forward returned. Answers {"success": true}.

Log Streaming

GET /devices/{id}/logs Stream device logs (SSE)

Streams the device log as Server-Sent Events: one JSON entry per data: line, with no event names. The stream stays open until you close it. On Android it starts with the lines already in the device's log buffer, then follows new ones. iPhones and simulators send only new lines. A device that cannot stream logs closes the stream with no events.

ParameterTypeDescription
processstringOnly entries whose process contains this text, case-insensitive. On a simulator, the exact process name or PID. On Android the process is the log tag.
levelstringLowest level to send. iPhone: Debug, Info, Notice, Warning, Error. Android: V, D, I, W, E, F. Simulator: default, info, debug.
tagstringAndroid only. Only entries with this exact tag.
containsstringOnly entries whose message contains this text, case-insensitive.

Stream

# Android
data: {"timestamp":"2026-11-05T10:15:02Z","process":"AndroidRuntime","pid":"5678","level":"E","tag":"AndroidRuntime","message":"FATAL EXCEPTION: main"}

# iPhone
data: {"timestamp":"2026-11-05T10:15:02","device":"iPhone","process":"SpringBoard","pid":"58","level":"Notice","message":"..."}

Fields: timestamp, process and message are always there. device is iPhone only and tag Android only. raw holds a line that could not be read into fields. Simulator entries carry the timestamp, the process and the rest of the line in message.

Crash Reports

GET /devices/{id}/crashes Read app crash reports

Returns the device's app crash reports, newest first, at most 10. Works on simulators, iPhones and Android devices. A simulator's reports are read from this computer and only that simulator's apps count. An iPhone's reports are copied to this computer first, so the first call can take a while. Android covers crashes, native crashes and ANRs, for installed apps and apps that came with the phone. Farm devices answer 501.

ParameterTypeDescription
appstringRegular expression matched against the bundle ID or package and the process name. A plain bundle ID also finds builds signed with an Apple ID, whose bundle ID carries a suffix.
sincestringAn RFC 3339 time, or a duration such as 10m. Default: the last 24 hours.

Response

{
  "reports": [{
    "time": "2026-11-05T10:12:44Z",
    "app": "com.example.shelf",
    "process": "Shelf",
    "kind": "crash",
    "exception": "EXC_BREAKPOINT (SIGTRAP)",
    "reason": "the app crashed: Shelf/BookList.swift:42: Fatal error: Index out of range",
    "frames": ["BookList.row(at:) (Shelf)", "closure #1 in BookList.body.getter (Shelf)"],
    "path": "/Users/you/Library/Logs/DiagnosticReports/Shelf-2026-11-05-101244.ips"
  }]
}

kind is crash, or on Android also native_crash or anr. reason says why in one sentence and names kills from outside the app, such as iOS ending an app whose main thread was blocked too long. frames is the top of the crashed thread, up to 15. path is the whole report, a file on this computer. A bad app or since answers 400.

Performance Metrics

Sample CPU, memory, frame rate, network and battery on a physical iPhone or an Android phone or emulator. Simulators are not supported. A device has one metrics session at a time: starting a new one stops the old one.

Metric typeWhat it samples
system_cpuCPU load of the whole device
system_memoryUsed, free and total memory
fpsFrames per second
networkBytes and packets in and out, for the app when bundleId is set
batteryLevel, charging, temperature, current
processCPU and memory of one app. Added on its own when bundleId or processName is set.
POST /devices/{id}/metrics/start Start a metrics session

Starts sampling in the background. Samples are also written to a file on this computer, named in the summary's data_file.

FieldTypeDescription
typesstring[]Metric types from the table above. Default system_cpu and system_memory.
intervalintegerMilliseconds between samples. Default 1000.
bundleIdstringAlso sample this app's own CPU and memory, and its network traffic.
processNamestringAlso sample this process's CPU and memory.
bufferSizeintegerSamples kept per type for GET /metrics. Default 1000.
labelstringA name for the session, shown in the summary.
captureLogsboolAlso save the device log's warnings and errors to a file, named in the summary's session.logs_file.
thresholdsobjectLimits behind the summary's warnings: cpu_high (80), fps_low (30), fps_jank (20), memory_growth_mb_min (50), memory_high (85), battery_drain_rate (10). Defaults in brackets. A value you leave out keeps its default.
{
  "types": ["system_cpu", "system_memory", "fps"],
  "bundleId": "com.example.shelf",
  "label": "checkout"
}
// Response
{"sessionId": "5f0c2a8e-3b1d-4c7a-9e57-2d8f6b1a0c94", "deviceId": "emulator-5554", "startedAt": "2026-11-05T10:15:02Z"}
POST /devices/{id}/metrics/stop Stop and get the summary

Stops the session. The body is optional. By default the answer is the summary, the same shape as GET /metrics/summary. With "format": "detailed" it is only the session ID and sample count. Answers 404 when the device has no session.

{"format": "detailed"}
// Response
{"sessionId": "5f0c2a8e-3b1d-4c7a-9e57-2d8f6b1a0c94", "samples": 183}
GET /devices/{id}/metrics/summary Summary so far

Returns the summary of the running session without stopping it. Each part is there only when its metric was sampled. app needs bundleId. Statuses are healthy, warning or critical.

Response (trimmed)

{
  "metrics_summary": {
    "session": {
      "label": "checkout",
      "duration_seconds": 61.4,
      "sample_count": 183,
      "session_id": "5f0c2a8e-3b1d-4c7a-9e57-2d8f6b1a0c94",
      "data_file": ".../mobai/data/metrics/5f0c2a8e-3b1d-4c7a-9e57-2d8f6b1a0c94.jsonl",
      "logs_available": false
    },
    "overall_health": "warning",
    "health_score": 74,
    "system_cpu": {"avg": 41.2, "max": 88.0, "min": 12.5, "p95": 79.3, "status": "warning"},
    "fps": {"avg": 52.1, "min": 18.0, "max": 60.0, "p5": 24.0, "jank_percent": 6.5, "status": "warning"},
    "app": {"bundle_id": "com.example.shelf", "cpu_avg": 22.4, "cpu_max": 61.0, "memory_avg_bytes": 182452224, "memory_max_bytes": 201326592, "memory_growth_mb": 14.2, "sample_count": 61, "status": "healthy"},
    "anomalies": {"fps_drops": [{"start_s": 12.0, "end_s": 15.0, "min_fps": 18.0, "avg_fps": 22.5, "samples": 3}]},
    "recommendations": ["FPS dropped to 18 at +12.0s (3 occurrences) - investigate screen transitions"]
  }
}
GET /devices/{id}/metrics Buffered samples

Returns the samples the running session holds, one array per type. Types with no samples are left out. 404 without a session.

{
  "cpu": [{"timestamp": "2026-11-05T10:15:03Z", "totalLoad": 23.5, "cpuCount": 6, "enabledCpu": 6}],
  "memory": [{"timestamp": "2026-11-05T10:15:03Z", "usedBytes": 3951820800, "freeBytes": 2490368000, "totalBytes": 6442188800, "usagePercent": 61.3, "physFootprint": 0}],
  "processes": [{"timestamp": "2026-11-05T10:15:03Z", "pid": 5678, "name": "com.example.shelf", "cpuUsage": 12.0, "memoryBytes": 182452224}]
}
GET /devices/{id}/metrics/stream Stream samples (SSE)

Server-Sent Events for the running session. Sends the buffered samples first, then new ones as they come. When the session stops it sends an end event and closes. 404 without a session.

data: {"type":"system_cpu","timestamp":"2026-11-05T10:15:03Z","data":{"timestamp":"2026-11-05T10:15:03Z","totalLoad":23.5,"cpuCount":6,"enabledCpu":6}}

event: end
data: session stopped
GET /devices/{id}/metrics/snapshot One reading, no session

Takes one reading without starting a session. ?types= is a comma-separated list of metric types, default system_cpu,system_memory. Unknown names are skipped.

{
  "timestamp": "2026-11-05T10:15:03Z",
  "cpu": {"timestamp": "2026-11-05T10:15:03Z", "totalLoad": 18.2, "cpuCount": 6, "enabledCpu": 6},
  "memory": {"timestamp": "2026-11-05T10:15:03Z", "usedBytes": 3951820800, "freeBytes": 2490368000, "totalBytes": 6442188800, "usagePercent": 61.3, "physFootprint": 0}
}

Debugging

Two ways in. Capture an app's console output while it runs, or open a debug session that pauses an iOS app at breakpoints so you can read its stack and variables.

App output

POST /devices/{id}/debug/launch Launch and log output to a file

Launches the app, restarting it if it runs, and writes everything it prints to a file on this computer until it exits. Answers once the app prints its first line, or 504 after 30 seconds of silence. Works on iPhones, simulators and Android. On Android it follows the app's log lines and clears the device's log buffer first. Stop it with POST /kill-app.

FieldTypeDescription
bundleIdstringThe app. Required.
logPathstringFolder for the log file. ~/ works. Default: mobai/debug in the system temp folder. The file is <bundleId>.log.
environmentobjectEnvironment variables for the app. iOS only.
argumentsstring[]Launch arguments. iOS only.
{"bundleId": "com.example.shelf", "environment": {"API_URL": "http://localhost:3000"}}
// Response
{"logPath": "/var/folders/xy/T/mobai/debug/com.example.shelf.log"}
// Each line of the file
2026-11-05T10:15:02.123456Z [stdout] Loaded 12 books
GET /devices/{id}/debug?bundleId=... Stream output (WebSocket)

A WebSocket that launches the app and sends its output as it comes. iOS only: iPhones and simulators. bundleId is required. To pass environment variables or arguments, send one JSON message within half a second of connecting. Each message from MobAI is JSON with type stdout, exit, crash or error.

// Connect
ws://127.0.0.1:8686/api/v1/devices/{id}/debug?bundleId=com.example.shelf

// Optional first message from you
{"environment": {"API_URL": "http://localhost:3000"}, "arguments": ["-reset"]}

// Messages from MobAI
{"type": "stdout", "data": "Loaded 12 books"}
{"type": "exit", "data": "0", "message": "Process exited normally"}

Debug sessions

A debug session pauses an iOS app at breakpoints. Works on simulators and on iPhones with iOS 17 or later. Needs Xcode on this Mac and a debug build of the app: App Store and TestFlight builds cannot be debugged. One session per device. Its state is running, paused or dead. Stack, threads, variables and eval need paused and answer 409 otherwise. Once the app exits the session is dead, calls answer 410, and you detach and attach again. While the app is paused, taps and other actions on it wait until it continues.

POST /devices/{id}/debug-session/attach Launch the app under the debugger

Launches the app fresh with the debugger attached. Answers 409 when the device already has a session.

FieldTypeDescription
bundleIdstringThe app. Required.
breakpointsstring[]Breakpoints set before the app runs. Same forms as POST /breakpoints.
stopOnEntryboolPause before any app code runs. Simulators only. Default false.
{"bundleId": "com.example.shelf", "breakpoints": ["BookList.swift:42"]}

// Response: the session
{
  "sessionId": "...",
  "deviceId": "6C1F2A0B-...",
  "pid": 48211,
  "bundleId": "com.example.shelf",
  "state": "running",
  "breakpoints": [{"id": 1, "spec": "BookList.swift:42", "verified": true, "source": {"path": "BookList.swift", "name": "BookList.swift"}, "line": 42}]
}
POST /devices/{id}/debug-session/attach-running Attach to a running app

Attaches to an app that is already running, by process ID. Takes breakpoints and stopOnEntry like attach. Answers the session.

{"pid": 48211, "breakpoints": ["BookList.swift:42"]}
GET /devices/{id}/debug-session Session state

Returns the session, the same shape attach answers. Poll it to see when a breakpoint pauses the app. 404 without a session.

POST /devices/{id}/debug-session/breakpoints Add a breakpoint

Works while the app runs. verified: false means it was not found: no debug info at that line, or a symbol name that does not match.

SpecBreaks at
BookList.swift:42A line, by file name or absolute path
Shelf.BookList.reloadA Swift function, without parameters or return type
-[BookCell prepareForReuse]An Objective-C method. +[...] for a class method.
swift_willThrowA plain symbol
{"spec": "BookList.swift:42"}
// Response
{"id": 2, "spec": "BookList.swift:42", "verified": true}
GET /devices/{id}/debug-session/breakpoints List breakpoints
{"breakpoints": [{"id": 2, "spec": "BookList.swift:42", "verified": true}]}
DELETE /devices/{id}/debug-session/breakpoints/{bpId} Remove a breakpoint

bpId is the id from add. Answers {"success": true}.

POST /devices/{id}/debug-session/step Step or continue

direction is in, over, out or continue. The first three wait for the next stop and answer with the stack and the top frame's variables. Set includeStack or includeVars to false to leave either out. Both default to true. continue answers the session at once: poll GET /debug-session for the next pause.

{"direction": "over"}

// Response for in, over, out
{
  "state": "paused",
  "breakpoints": [...],
  "stack": [{"id": 1, "name": "BookList.reload()", "source": {"path": "/Users/you/code/shelf/BookList.swift", "name": "BookList.swift"}, "line": 43, "column": 9}],
  "frame0Locals": [{"name": "Locals", "variables": [{"name": "count", "value": "12", "type": "Int"}]}]
}
GET /devices/{id}/debug-session/stack Stack frames

Frames of the thread that stopped, or of ?threadId=.

{"frames": [{"id": 1, "name": "BookList.reload()", "source": {"path": "/Users/you/code/shelf/BookList.swift", "name": "BookList.swift"}, "line": 42, "column": 9}]}
GET /devices/{id}/debug-session/threads Threads
{"threads": [{"id": 1, "name": "Thread 1"}]}
GET /devices/{id}/debug-session/frames/{frameId}/variables Frame variables

frameId is a frame id from the stack.

{"scopes": [{"name": "Locals", "variables": [{"name": "count", "value": "12", "type": "Int"}]}]}
POST /devices/{id}/debug-session/eval Evaluate an expression

Takes what Xcode's debugger console takes: p, po, frame variable. frameId is optional and defaults to the top frame of the thread that stopped.

{"expression": "po self.books.count"}
// Response
{"result": "12"}
DELETE /devices/{id}/debug-session End the session

Ends the session. The app keeps running unless you send kill: true. The body is optional.

{"kill": true}

Hot Reload

MobAI keeps a Flutter or React Native hot reload session open for a device, so a script or a coding agent can reload the app and read its output over HTTP. One session per device. On a device connected to this computer, saving a file reloads by itself: Flutter for files under lib/, React Native through Fast Refresh. The session belongs to the device, not to the caller that started it. It works on devices connected to this computer and on CI devices. Devices connected to another MobAI answer 400.

POST /devices/{id}/dev Start a session

Stops the device's session if there is one, then starts a new one. Answers 200 once the app runs with hot reload on, or 422 with the failed session and its log. A Flutter build can take minutes. React Native needs a Debug build, and an iPhone also needs the same Wi-Fi as this computer and Local Network access for the app.

FieldTypeDescription
frameworkstringflutter or react-native. Read from the folder when left out.
dirstringThe project folder. Required.
bundleIdstringThe installed debug build to launch. Required for React Native, and for Flutter on an iPhone when this computer has no Xcode or when attach is true.
metroPortintegerReact Native only. Default 8081.
attachboolFlutter only. Attach to the installed debug build instead of building it again.
{"framework": "flutter", "dir": "/Users/you/code/shelf"}

// Response: the session
{
  "deviceId": "6C1F2A0B-...",
  "framework": "flutter",
  "dir": "/Users/you/code/shelf",
  "state": "running",
  "vmServiceUrl": "http://127.0.0.1:50123/AbCdEf=/",
  "log": ["Syncing files to device iPhone 16 Pro...", "Flutter run key commands."]
}
GET /devices/{id}/dev Session and log

Returns the session with the last lines of its log. ?lines= sets how many, default 80. The session keeps the last 400. 404 without a session.

FieldDescription
statestarting, running, stopped or failed
vmServiceUrlFlutter's debug service, for Flutter sessions
metroUrlMetro's address, for React Native sessions
errorWhy the session failed or stopped
resultWhat the last reload printed, in reload answers
logThe last lines of output from the tools and the app
POST /devices/{id}/dev/reload Reload now

Flutter: a hot reload, or a hot restart with restart: true. React Native: reloads the app, and restart is ignored. The body is optional. Answers the session with result. A failed reload answers 422 with error, for example code that does not compile, with the compiler errors in log. 404 without a session.

{"restart": false}
// Response (trimmed)
{"state": "running", "result": "Reloaded 1 of 812 libraries in 412ms.", "log": [...]}
DELETE /devices/{id}/dev End the session

Ends the session and the app it launched. Answers {"stopped": true}, or 404 without a session.

POST /devices/{id}/rebuild Rebuild on a CI device

CI devices only: devices a mobai-ci run shares with the app. Builds the folder as it is now on the CI machine with its mobai.json build command, else the job's own build, then installs and launches the app. Ends any hot reload session on the device. Answers when the build ends: 200 when done, 422 with the end of the build log when it failed. Other devices answer 400.

FieldTypeDescription
dirstringThe project folder. Required.
bundleIdstringThe app to launch. On iOS it is read from the build when left out.
{"dir": "/Users/you/code/shelf"}
// Response (trimmed)
{"state": "done", "appPath": "build/ios/iphonesimulator/Runner.app", "bundleId": "com.example.shelf", "launched": true, "log": ["..."]}

CI devices: POST /dev, POST /dev/reload and POST /rebuild send the folder as it is now, committed or not, to the CI machine, and the session runs there. The folder must be a clone of the repository the CI job runs.

Simulators

Manage iOS simulators. macOS only: other systems answer 501. A booted simulator shows up in GET /devices a moment later. Boots are always stock. Slimming happens only when you ask for it.

GET /simulators List simulators

Every available iOS simulator, booted or not. runtime is the runtime identifier, the same value POST /simulators takes.

[{
  "udid": "6C1F2A0B-5D3E-4F7A-9B21-8E0C4D6A1F33",
  "name": "iPhone 16 Pro",
  "state": "Booted",
  "runtime": "...iOS-18-2",   // trimmed
  "osVersion": "18.2"
}]
POST /simulators Create a simulator

Creates a simulator and answers 201. It stays shut down until you boot it. Answers 422 when no installed iOS runtime supports the device type.

FieldTypeDescription
deviceTypestringA device type name or identifier, such as iPhone 16 Pro. Required.
runtimestringA runtime identifier. Empty or latest: the newest installed iOS runtime that supports the device type.
namestringDefault: the device type.
{"deviceType": "iPhone 16 Pro", "name": "Test iPhone"}
// Response
{"udid": "0B7E1C44-2A9D-4E61-8F05-3C1D7A9B2E10", "runtime": "...iOS-18-2"}
POST /simulators/{udid}/boot Boot

Boots the simulator without opening the Simulator app and answers 204 once it is ready, within 2 minutes. A simulator that is already booted is fine.

POST /simulators/{udid}/shutdown Shut down

Shuts the simulator down. Answers 204.

POST /simulators/{udid}/clone Clone

Makes a copy with the same apps and data, for running tests in parallel. name is required. A booted source is shut down for the copy and booted again after. Answers 201, within 5 minutes.

{"name": "iPhone 16 Pro (2)"}
// Response
{"udid": "9D2B6F10-7C3A-4B85-A1E4-5F0D2C8B7A66"}
POST /simulators/{udid}/erase Erase

Erases all content and settings. A booted simulator is shut down first and stays shut down. Answers 204.

DELETE /simulators/{udid} Delete

Deletes the simulator, shutting it down first if it runs. Answers 204.

GET /simulators/{udid}/slim Slimming status

Says how many of the background services MobAI can turn off are off. The simulator must be booted, else 422. persistent says whether the runtime keeps them off across reboots.

{"managedDisabled": 180, "managedTotal": 180, "booted": true, "persistent": true, "slim": true}
POST /simulators/{udid}/slim Turn slimming on or off

Slimming turns off background services a simulator does not need for testing, such as Siri, Spotlight, photo analysis and iCloud, so it uses far less memory and more simulators fit on one Mac. The simulator restarts to apply the change and is booted when the call answers. Needs an iOS 18.5 or later runtime, else 422.

FieldTypeDescription
enabledbooltrue to slim, false to go back to stock.
exceptstringComma-separated groups to leave running: widgets, siri, search, icloud, store, pim, web, family, health, photos, apps, messaging, connectivity, telemetry, other.
keepstringComma-separated service names to leave running.
{"enabled": true, "except": "store"}
// Response
{"changed": true, "slim": true}

Named APIs

A named API is a .mob script saved in MobAI's APIs folder. Calling it runs the script on a device and returns the values its extract steps captured. The folder is ~/Library/Application Support/mobai/data/apis on macOS, %AppData%\mobai\data\apis on Windows and ~/.config/mobai/data/apis on Linux. Subfolders work: apis/youtube/search.mob is called youtube/search.

# Search YouTube
# Param: query
siri "Search YouTube for ${query}"
wait_for ~"${query}" timeout:5000
extract result from ~"${query}"
GET /apis List named APIs
[{"name": "youtube/search", "path": "/Users/you/Library/Application Support/mobai/data/apis/youtube/search.mob"}]
POST /apis/run/{name} Run a named API

Runs the script and answers when it ends. The bridge starts on its own. The answer is the map of extracted values itself, or null for a script with no extract steps. Runs are not saved. Needs the MobAI desktop app: a host started with mobai serve answers 503.

FieldTypeDescription
device_idstringThe device to run on. Required.
paramsobjectString values for the script's # Param: lines.
{"device_id": "00008101-001A0C3E1234", "params": {"query": "cats"}}
// Response
{"result": "cats"}

404: no such API or device. 422: the script failed, with the reason in error.

Tests

Find and run the .mob and Maestro tests in the folders open in MobAI's Testing section. Needs the MobAI desktop app: a host started with mobai serve answers 503.

GET /tests/projects Test folders and their tests

Every project folder with its tests. kind is mob, maestro, flow, js or config. missing: true marks a folder that no longer exists.

[{
  "dir": "/Users/you/code/shelf/tests",
  "name": "tests",
  "cases": [{"id": "auth/login.mob", "name": "login", "relPath": "auth/login.mob", "kind": "mob", "tags": ["smoke"], "modified": "2026-11-04T18:20:11Z"}]
}]
GET /tests/active Project and file open in the app

The project and the test file open in the app now. project_dir is null when no project is open.

{"project_dir": "/Users/you/code/shelf/tests", "open_file": "auth/login.mob"}
POST /tests/projects/open Open a test folder

Adds a folder to Testing and makes it the open project. Answers 201.

{"dir": "/Users/you/code/shelf/tests"}
// Response
{"dir": "/Users/you/code/shelf/tests", "name": "tests"}
POST /tests/cases/run Run a test

Runs one test and answers when it ends. The bridge starts on its own. The run is saved with the project's other runs. A failing test still answers 200, with passed: false. Answers 409 when a test already runs on the device.

FieldTypeDescription
project_dirstringThe project folder. Required.
case_pathstringThe test file, relative to project_dir: a .mob file or a Maestro .yaml. Required.
device_idstringThe device. Required.
paramsobjectString values for the test's # Param: lines.
{"project_dir": "/Users/you/code/shelf/tests", "case_path": "auth/login.mob", "device_id": "emulator-5554"}

// Response (trimmed)
{
  "caseId": "auth/login.mob",
  "caseName": "login",
  "deviceId": "emulator-5554",
  "deviceName": "Pixel 9",
  "startedAt": "2026-11-05T10:15:02Z",
  "finishedAt": "2026-11-05T10:15:19Z",
  "passed": false,
  "stepResults": [{"index": 0, "lineNumber": 4, "lineText": "tap \"Sign in\"", "passed": false, "startedAtMs": 0, "durationMs": 5012, "error": "..."}],
  "error": "...",
  "extracted": {"user": "ada"}
}

Server

GET /health Is MobAI running

Answers when MobAI's API is up. requireDeviceClaim says whether callers must claim a device before driving it. headless is true for a host started with mobai serve. sharing says whether this computer shares its devices with other MobAI computers.

{
  "status": "ok",
  "service": "mobai",
  "hostname": "studio.local",
  "node_id": "...",
  "sharing": false,
  "requireDeviceClaim": false,
  "version": "4.0.0",
  "deviceCount": 2,
  "pid": 81234,
  "headless": false,
  "time": "2026-11-05T10:15:02Z"
}
GET /version MobAI version
{"version": "4.0.0", "buildTime": "2026-11-03T14:02:51Z", "gitCommit": "d864896c"}
GET /openapi.json OpenAPI document

An OpenAPI 3.0 document for the core device routes. Many routes on this page, such as web automation, metrics, debug sessions and simulators, are not in it.

GET /docs Interactive API page

A page in the browser for trying the routes in /openapi.json. It loads its viewer from the internet, so it needs a connection.