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": "..."}
| Status | Meaning |
|---|---|
| 400 | The body or a parameter is wrong |
| 402 | The plan does not cover it. code is device_limit_reached (Free drives one device at a time) or pro_required |
| 403 | A safety rule in the app blocked the action |
| 404 | No such device, app or resource |
| 409 | The device is claimed by someone else, or a claim is required |
| 500 | The 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:.
/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
}]
| Field | Type | Description |
|---|---|---|
| id | string | Serial number (Android) or UDID (iOS). Use it in every /devices/{id} route. |
| name | string | Device name |
| platform | string | android or ios |
| model | string | Hardware model |
| osVersion | string | OS version |
| bridgeRunning | bool | True when the bridge is up and the device takes actions |
| virtual | bool | True for simulators and emulators |
| inUse | bool | Present while someone holds a claim on the device |
| inUseBy | string | The holder label of that claim, when it has one |
| remote | bool | True for a device attached to another MobAI computer. Tools on this computer cannot reach it directly. |
| node | string | Name of that other computer |
| cloud | bool | True for a cloud device farm session |
| transport | string | How an iPhone is connected: USB, Network or Tailscale. Other devices may name their own. |
| connectionState | string | ready when transport is known |
| controlUrl | string | A page on this computer for watching and controlling the device in a browser |
| screenWidth, screenHeight | integer | Screen 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.
/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}
| Caller | Claim needed? |
|---|---|
| This computer | No, unless Require device claim is on in the API server settings. A token sent anyway still renews its claim. |
| This computer, Require device claim on | Yes, 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 computer | Always. 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.
/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.
| Field | Type | Description |
|---|---|---|
| device | string | Device id, or device name (case does not matter). Leave out for any free device. |
| ttlSeconds | integer | Claim lifetime without activity. Default 1800 from another computer, the idle timeout setting from this one. |
| holder | string | A label for who holds it, shown as inUseBy and in conflict errors |
| clientId | string | A 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. |
| owner | string | Groups 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", ...}
}
| Status | Code | Meaning |
|---|---|---|
| 404 | No device matches device | |
| 409 | DEVICE_IN_USE | Someone else holds the device, or every matching device. The error names the holder when the claim has one. |
| 409 | No 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.
/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.
/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
/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.
| Parameter | Type | Description |
|---|---|---|
| format | string | base64 returns the image as data and saves no file |
| path | string | Folder to save into. ~/ works. Default: mobai/screenshots in the system temp folder. |
| name | string | File name. The extension is added when missing. Default: <device id>-<unix time>. |
| low_quality | bool | true 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.
/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.
| Parameter | Type | Description |
|---|---|---|
| onlyVisible | bool | Only elements on screen (default: true) |
| includeKeyboard | bool | Include the on-screen keyboard's keys (default: false) |
| verbose | bool | Also return an elements array with every field (default: false) |
| compact | bool | Drop coordinates and indentation from the text to save tokens (default: false) |
| textRegex | string | Regular 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. |
| boundsX | integer | Left edge of a rectangle. Only elements fully inside it are returned. Use with boundsY, boundsW, boundsH. |
| boundsY | integer | Top edge of the rectangle |
| boundsW | integer | Width of the rectangle |
| boundsH | integer | Height 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.
/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": "..."}.
| Status | Meaning |
|---|---|
| 400 | Bad body or a missing field |
| 402 | Plan limit. device_limit_reached: the Free plan drives one device at a time. pro_required: the device needs Pro. |
| 403 | The device's Safety Settings block the action |
| 404 | No device with that id |
| 409 | A claim is needed or someone else holds the device. See Device Claims. |
| 503 | The iPhone's bridge could not be prepared |
| 500 | The device reported an error. The message says what went wrong. |
/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.
| Field | Type | Description |
|---|---|---|
| index | integer | Element index from the UI tree. Taps its center. Out of range answers 400. |
| x, y | integer | Point to tap |
| xPercent, yPercent | number | Point 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
/devices/{id}/double-tap
Double tap
Double taps. Same fields and response as /tap.
/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}
/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.
/devices/{id}/pinch
Pinch to zoom
| Field | Type | Description |
|---|---|---|
| x, y | integer | Center of the pinch |
| scale | number | Required. Above 1.0 zooms in, below 1.0 zooms out. |
| velocity | number | Scale change per second (default: 1.0) |
{"x": 196, "y": 426, "scale": 2.0}
Response
{"success": true, "data": {"x": 196, "y": 426, "scale": 2}}
/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}
/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.
| Field | Type | Description |
|---|---|---|
| fromX, fromY, toX, toY | integer | Start and end points. All four at 0 answers 400. |
| duration | integer | Time to move, in ms (default: 500) |
| pressDuration | integer | Hold before moving, in ms. Use it to pick up an item that needs a long press. |
| holdDuration | integer | Hold at the end before letting go, in ms. Stops a list from flinging on. |
{"fromX": 100, "fromY": 200, "toX": 300, "toY": 400, "pressDuration": 800}
/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}
]}
/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.
| Field | Type | Description |
|---|---|---|
| targetText | string | Required. Text the element contains. |
| listIndex | integer | UI tree index of the list to scroll inside. Leave out to scroll the screen. |
| direction | string | down (default), up, left or right |
| maxScrolls | integer | How 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}
/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.
| Field | Type | Description |
|---|---|---|
| listIndex | integer | UI tree index of the list |
| maxElements | integer | Stop after this many (default: 0, no limit) |
| direction | string | down (default) or up |
Response
{
"elements": [
{"index": 12, "type": "StaticText", "text": "Airplane Mode", "x": 60, "y": 190, "width": 120, "height": 22}
],
"count": 1
}
/devices/{id}/type
Type text
Types into the field that has focus. Tap the field first.
{"text": "Hello World"}
/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.
/devices/{id}/press-key
Press a hardware or system key
Presses a key by name. A name the platform does not have answers 400.
| Platform | Keys |
|---|---|
| iOS | home, volume_up, volume_down |
| Android | home, back, recent_apps, enter, tab, delete, escape, volume_up, volume_down, mute, power, play_pause, next, previous |
{"key": "back"}
/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.
/devices/{id}/go-home
Go to the home screen
Goes to the home screen. No body.
/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
/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
}
}
/devices/{id}/clipboard
Read the clipboard
Returns the plain text on the device clipboard.
{"text": "123456"}
/devices/{id}/clipboard
Set the clipboard
Puts plain text on the device clipboard, ready to paste.
{"text": "[email protected]"}
/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.
/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.
/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.
| Field | Type | Description |
|---|---|---|
| state | string | closed, book (half open) or open |
| angle | number | Hinge angle from 0 (closed) to 180 (flat). Wins over state when both are sent. |
{"state": "open"}
{"angle": 90}
Apps
/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"]}}
]
/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.
| Field | Type | Description |
|---|---|---|
| path | string | Required. File on this computer. |
| resign | bool | iPhones only. Sign the IPA for this iPhone with an Apple ID before installing. |
| appleId | string | Apple ID to sign with |
| password | string | Apple ID password. Leave out to use the saved login. |
| profilePath, certPath, keyPath | string | Files 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.
/devices/{id}/launch-app
Launch an app
Opens an app by bundle id (iOS) or package name (Android).
| Field | Type | Description |
|---|---|---|
| bundleId | string | Required. Bundle id or package name. |
| arguments | string[] | For this launch only. Launch arguments on iOS, intent extras on Android (for example ["--ez", "UITesting", "true"]). |
| environment | object | Environment variables for this launch only. iOS only. Android answers with an error. |
| withDebugger | bool | iPhones only. Starts the app under a debugger, which Flutter debug builds need to run on an iPhone. Other platforms answer 400 or 500. |
| noWait | bool | Return 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.
/devices/{id}/kill-app
Force-quit an app
Force-quits a running app. bundleId is required.
{"bundleId": "com.example.app"}
/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.
/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.
| Field | Type | Description |
|---|---|---|
| app | string | Cloud devices: the app to test, as the farm's reference (from install-app) |
| runOptions | object | Cloud 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.
/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
/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.
| Field | Type | Description |
|---|---|---|
| task | string | Required. What to do. |
| agentType | string | Which agent runs it. Default: the one picked in the app, else toolagent. |
| useVision | bool | true lets the agent look at screenshots. false keeps the app setting. |
| agentType | Needs |
|---|---|
| toolagent | An LLM provider key. Fast, best for simple tasks. |
| hierarchical | An LLM provider key. A manager plans, a runner acts. |
| classic | An LLM provider key. Plans each step carefully. |
| claude-code | Claude Code installed on this computer. An LLM provider key must also be set in the app. |
| jev | A TypeSafe key |
| clef | Clef settings (Cloudflare or self-hosted) |
| luna | An 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.
/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.
| Field | Type | Description |
|---|---|---|
| steps | array | Required. The steps to run. |
| version | string | "0.2". Accepted but not checked. |
| on_fail | object | What to do when a step fails: strategy (abort, skip, retry, replan, require_user), max_retries, retry_delay_ms, fallback_strategy, message |
| defaults | object | timeout_ms for steps that set none (default 120000), context (native or web) |
| mode | string | explore (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. |
| alerts | string | accept or dismiss system alerts (permission prompts, crash dialogs) that block a step. Default: leave them. |
| settle | bool | Wait up to 500 ms for a moving target to stop before tapping it |
| params | object | Named 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.
/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
}]
/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}
/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>"}
/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}
/devices/{id}/web/click
Click by CSS selector
Clicks the first element that matches a CSS selector. Fails when nothing matches.
{"selector": "button.submit"}
/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]"}
/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"}
/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.
/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.
| Field | Type | Description |
|---|---|---|
| devicePort | integer | Port on the device. Required. |
| hostPort | integer | Port on this computer. Default 0: pick a free one. |
{"devicePort": 8080}
// Response
{"hostPort": 49152, "devicePort": 8080, "id": "android-emulator-5554-49152-8080-1762337702000000000"}
/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
/devices/{id}/forward
List forwards
[{"id": "android-emulator-5554-49152-8080-1762337702000000000", "hostPort": 49152, "devicePort": 8080}]
/devices/{id}/forward/{forwardId}
Stop a forward
Stops the forward with the id that POST /forward returned. Answers {"success": true}.
Log Streaming
/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.
| Parameter | Type | Description |
|---|---|---|
| process | string | Only 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. |
| level | string | Lowest level to send. iPhone: Debug, Info, Notice, Warning, Error. Android: V, D, I, W, E, F. Simulator: default, info, debug. |
| tag | string | Android only. Only entries with this exact tag. |
| contains | string | Only 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
/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.
| Parameter | Type | Description |
|---|---|---|
| app | string | Regular 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. |
| since | string | An 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 type | What it samples |
|---|---|
| system_cpu | CPU load of the whole device |
| system_memory | Used, free and total memory |
| fps | Frames per second |
| network | Bytes and packets in and out, for the app when bundleId is set |
| battery | Level, charging, temperature, current |
| process | CPU and memory of one app. Added on its own when bundleId or processName is set. |
/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.
| Field | Type | Description |
|---|---|---|
| types | string[] | Metric types from the table above. Default system_cpu and system_memory. |
| interval | integer | Milliseconds between samples. Default 1000. |
| bundleId | string | Also sample this app's own CPU and memory, and its network traffic. |
| processName | string | Also sample this process's CPU and memory. |
| bufferSize | integer | Samples kept per type for GET /metrics. Default 1000. |
| label | string | A name for the session, shown in the summary. |
| captureLogs | bool | Also save the device log's warnings and errors to a file, named in the summary's session.logs_file. |
| thresholds | object | Limits 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"}
/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}
/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"]
}
}
/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}]
}
/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
/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
/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.
| Field | Type | Description |
|---|---|---|
| bundleId | string | The app. Required. |
| logPath | string | Folder for the log file. ~/ works. Default: mobai/debug in the system temp folder. The file is <bundleId>.log. |
| environment | object | Environment variables for the app. iOS only. |
| arguments | string[] | 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
/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.
/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.
| Field | Type | Description |
|---|---|---|
| bundleId | string | The app. Required. |
| breakpoints | string[] | Breakpoints set before the app runs. Same forms as POST /breakpoints. |
| stopOnEntry | bool | Pause 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}]
}
/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"]}
/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.
/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.
| Spec | Breaks at |
|---|---|
| BookList.swift:42 | A line, by file name or absolute path |
| Shelf.BookList.reload | A Swift function, without parameters or return type |
| -[BookCell prepareForReuse] | An Objective-C method. +[...] for a class method. |
| swift_willThrow | A plain symbol |
{"spec": "BookList.swift:42"}
// Response
{"id": 2, "spec": "BookList.swift:42", "verified": true}
/devices/{id}/debug-session/breakpoints
List breakpoints
{"breakpoints": [{"id": 2, "spec": "BookList.swift:42", "verified": true}]}
/devices/{id}/debug-session/breakpoints/{bpId}
Remove a breakpoint
bpId is the id from add. Answers {"success": true}.
/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"}]}]
}
/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}]}
/devices/{id}/debug-session/threads
Threads
{"threads": [{"id": 1, "name": "Thread 1"}]}
/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"}]}]}
/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"}
/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.
/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.
| Field | Type | Description |
|---|---|---|
| framework | string | flutter or react-native. Read from the folder when left out. |
| dir | string | The project folder. Required. |
| bundleId | string | The 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. |
| metroPort | integer | React Native only. Default 8081. |
| attach | bool | Flutter 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."]
}
/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.
| Field | Description |
|---|---|
| state | starting, running, stopped or failed |
| vmServiceUrl | Flutter's debug service, for Flutter sessions |
| metroUrl | Metro's address, for React Native sessions |
| error | Why the session failed or stopped |
| result | What the last reload printed, in reload answers |
| log | The last lines of output from the tools and the app |
/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": [...]}
/devices/{id}/dev
End the session
Ends the session and the app it launched. Answers {"stopped": true}, or 404 without a session.
/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.
| Field | Type | Description |
|---|---|---|
| dir | string | The project folder. Required. |
| bundleId | string | The 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.
/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"
}]
/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.
| Field | Type | Description |
|---|---|---|
| deviceType | string | A device type name or identifier, such as iPhone 16 Pro. Required. |
| runtime | string | A runtime identifier. Empty or latest: the newest installed iOS runtime that supports the device type. |
| name | string | Default: the device type. |
{"deviceType": "iPhone 16 Pro", "name": "Test iPhone"}
// Response
{"udid": "0B7E1C44-2A9D-4E61-8F05-3C1D7A9B2E10", "runtime": "...iOS-18-2"}
/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.
/simulators/{udid}/shutdown
Shut down
Shuts the simulator down. Answers 204.
/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"}
/simulators/{udid}/erase
Erase
Erases all content and settings. A booted simulator is shut down first and stays shut down. Answers 204.
/simulators/{udid}
Delete
Deletes the simulator, shutting it down first if it runs. Answers 204.
/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}
/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.
| Field | Type | Description |
|---|---|---|
| enabled | bool | true to slim, false to go back to stock. |
| except | string | Comma-separated groups to leave running: widgets, siri, search, icloud, store, pim, web, family, health, photos, apps, messaging, connectivity, telemetry, other. |
| keep | string | Comma-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}"
/apis
List named APIs
[{"name": "youtube/search", "path": "/Users/you/Library/Application Support/mobai/data/apis/youtube/search.mob"}]
/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.
| Field | Type | Description |
|---|---|---|
| device_id | string | The device to run on. Required. |
| params | object | String 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.
/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"}]
}]
/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"}
/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"}
/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.
| Field | Type | Description |
|---|---|---|
| project_dir | string | The project folder. Required. |
| case_path | string | The test file, relative to project_dir: a .mob file or a Maestro .yaml. Required. |
| device_id | string | The device. Required. |
| params | object | String 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
/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"
}
/version
MobAI version
{"version": "4.0.0", "buildTime": "2026-11-03T14:02:51Z", "gitCommit": "d864896c"}
/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.
/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.