# Documentation ## Introduction URL: /docs Limrun is cloud infrastructure for mobile development. Run iOS simulators, Android emulators, and Xcode builds in the cloud, controlled through one CLI or SDK. Introduction Limrun lets cloud coding agents do mobile development. macOS for iOS, accelerated emulators for Android, and physical-device hardware all run as on-demand cloud services, so an agent on a Linux VM can build iOS apps without a Mac, drive simulators from a CI runner, embed a live simulator in a web app, or share a preview URL reviewers open in any browser. Three products Limrun is three composable cloud services: | Product | What it is | What you do with it | |---|---|---| | iOS simulator | A real Mac running the iOS Simulator | Stream it in a browser. Drive it programmatically: taps, screenshots, video, app launch, log streaming, element queries. | | Xcode build sandbox | A real Mac running xcodebuild | Sync your source, run xcodebuild remotely, get the logs back. | | Android emulator | A booted emulator with an ADB tunnel | Local Android Studio, Appium, and scrcpy attach as if it were a USB device. | The iOS simulator and Xcode sandbox can be created as a paired instance in one call. A successful build auto-installs on the attached simulator, which is the most common shape. Limrun also offers Asset Storage for build artifacts: .apk for Android, and .app folders zipped or tarballed for iOS simulator installs. Upload once, then pre-install on new instances or share via preview URLs. See the Quickstart for a three-minute walkthrough. Common use cases Shipping iOS or Android code from a cloud coding agent. A coding agent picks up a ticket on its Linux VM, calls Limrun to build and run the app on a real Mac, drives the simulator to verify the change, and posts a preview link on the PR for reviewers. Mobile builds and tests from Linux CI. Run xcodebuild from a Linux runner, drive Appium tests against cloud simulators, post artifacts and preview links on every PR. No macOS minutes required. Embedded simulators inside a product. A backend creates instances on behalf of users; the frontend embeds ` from @limrun/ui to stream the live simulator into the product UI. The CLI wraps the SDK; both reach every operation. Architecture An instance is anything you create through Limrun: an iOS simulator, an Android emulator, or an Xcode build sandbox. Each one has an ID, exposes its own endpoints, and lives until you delete it or its inactivity timeout fires. Limrun is split into a control plane (where you create and manage instances) and per-instance data planes (where you actually interact with each instance). Your org API key authenticates the control plane; each instance returns its own per-instance token that authenticates everything tied to that instance, including its MCP server. Each instance also exposes a signed stream URL with a token baked into it. Anyone with the URL can open the running simulator in a browser without signing in. This is what lets a cloud agent surface the live device to a human reviewer (a Slack link, a PR comment, a browser pane inside the agent's own UI) without putting them through an interactive auth flow. Drive Limrun from your environment with the lim CLI (for cloud agents and CI), the @limrun/api SDK in TypeScript / Python / Go (for backends that provision instances on behalf of users), or from @limrun/ui (to embed a live device in a web app). All three wrap the same REST API. MCP-aware tools can call the per-instance MCP server directly. How to get started Get the CLI installed and run an iOS app on a remote simulator in three minutes. The cookbook page coding agents use to drive a build, test, ship loop. Stream a live iOS or Android instance into your web app with from @limrun/ui. Source sync, xcodebuild` over SSE, code signing, IPA upload to Asset Storage. --- ## CLI for coding agents URL: /docs/agents/cli Install the `lim` CLI in your coding agent's environment. The agent then builds your iOS app on a remote Mac, drives the simulator, and posts preview links on PRs. CLI for coding agents Coding agents drive Limrun through the lim CLI. Once it's installed and authenticated, the agent can create a simulator, build your app, tap through it, record video, and share a preview link without leaving its shell. Setup is short: install the CLI, set your API key, hand the agent the prompt above (or install the bundled skill). The rest of this page is the same guide the skill gives your agent, plus the command reference behind it. One-time setup 1. Install the CLI Needs Node.js 20 LTS or newer. 2. Set your API key Set LIMAPIKEY in the environment your agent runs in. The CLI also picks up .env files in the working directory. 3. Give your agent the instructions Either copy the prompt at the top of this page into your agent, or install the bundled skill so the agent picks it up automatically: The CLI detects which coding agents you have on the machine (Claude Code, Cursor, Codex), asks which ones to set up, then asks whether to install into the current project or globally. The default skill is limrun-ios, one short Markdown file you can read at typescript-sdk/packages/cli/skills/limrun-ios/SKILL.md. What the agent does The loop the skill walks the agent through: 1. Create or reuse an instance. One lim ios create --xcode boots a simulator and an attached Mac sandbox. Labels let re-runs land on the same instance. 2. Build the app. lim xcode build . syncs the source to the sandbox, runs xcodebuild remotely, streams the logs back, and installs the build on the simulator on success. 3. Read the screen, then act. Before every tap, the agent reads lim ios element-tree and picks elements by accessibility ID. No flaky pixel coordinates. 4. Record video for changes that involve motion. 5. Upload the build and share a preview link so reviewers open the app in any browser. 6. Delete the instance when the work is done. The rest of this page is the command reference behind each step. Create an instance --xcode attaches a Mac sandbox to the simulator (required for lim xcode build). --reuse-if-exists returns an existing instance in the same region instead of provisioning a new one, so retries land back on the same warm instance. Scope reuse to a specific task by adding a label (--label issue=); without one, every reuse call in your org matches the same unlabeled instance. The output includes the instance ID, the Signed Stream URL, and the Xcode sandbox URL. Open the Signed Stream URL in any browser to watch the simulator live; the token is in the URL, so no login is required. Share the Signed Stream URL with the user at the start of a task. Useful flags | Flag | What it does | |---|---| | --xcode | Attach a Mac sandbox. Required for lim xcode build. | | --reuse-if-exists | Return an existing instance with the same labels instead of creating a new one. | | --label key=value | Repeatable. Used by --reuse-if-exists and for cleanup. | | --region us-west | Pin to a region. | | --inactivity-timeout 10m | Auto-delete after this much idle time. 1m, 10m, 3h. Org default applies if you skip it. | | --hard-timeout 3h | Force-delete after this much wall time. 0 (default) means no cap. | | --install ./build.app | Local app to upload and install at boot. Repeatable. | | --install-asset | Pre-installed asset from Asset Storage. Repeatable. | | --model iphone\|ipad\|watch | Device model. Defaults to iphone. | | --rm | Delete the instance when the CLI process exits. Handy for one-shot runs. | Build the app This is the only build command. Don't use local xcodebuild. The CLI syncs your working directory to the Mac sandbox (only the changed bytes), runs xcodebuild server-side, streams the logs back, and installs the build on the attached simulator on success. For workspaces or multi-scheme projects: Build flags | Flag | What it does | |---|---| | --scheme MyApp | Xcode scheme name. | | --workspace path.xcworkspace | Use a workspace file. | | --project path.xcodeproj | Use a project file (alternative to --workspace). | | --sdk iphonesimulator\|iphoneos\|watchsimulator\|watchos | Target SDK. | | --upload | Save the build artifact to Asset Storage under this name. Used for preview links. | | --signed-upload-url | Upload to your own pre-signed URL (S3, GCS, etc). | | --certificate-p12 path.p12 --certificate-password ... --provisioning-profile path.mobileprovision | Sign for a real-device build. | | --additional-file local=remote | Sync an extra file (like ~/.netrc) into the sandbox on every build. Repeatable. | | --ignore 'regex' | Skip paths during sync. Repeatable. | | --basis-cache-dir path --max-patch-bytes N | Tune the local sync cache. | The sync skips .git, .DS_Store, the basis cache, and anything in your .gitignore. .xcconfig files are always synced. If your .gitignore doesn't cover DerivedData/ or xcuserdata/, add them with --ignore. Read the screen, then act Always read state before the next action. The element tree shows every label, accessibility ID, type, and frame on the screen. It's the source of truth, not a screenshot. The tree can be big. Pipe through grep or jq so it doesn't fill your agent's context window: For a visual check, take a screenshot: Tap, type, scroll Find elements by accessibility ID first, then label, then coordinates as a last resort. The first two survive UI changes; coordinates don't. tap-element accepts --ax-unique-id, --ax-label, --ax-label-contains, --type, --title, --title-contains, and --ax-value. Combine them to narrow the match. Text, keys, scrolling, deep links: Run several actions in one shot When a sequence shouldn't have round-trip latency between steps, use lim ios perform. The server runs the whole batch and stops on the first failure. Or from a file: Supported types: tap, tapElement, incrementElement, decrementElement, setElementValue, typeText, pressKey, scroll, toggleKeyboard, openUrl, setOrientation, wait, touchDown, touchMove, touchUp, keyDown, keyUp, buttonDown, buttonUp. Hardware button names for buttonDown and buttonUp: home, lock, side, applePay, softwareKeyboard. Apps and logs Stream live logs or tail the last N lines: Record a video For anything involving motion (animations, gestures, gameplay), record a video instead of taking screenshots. Always attach it to the PR. --quality 5 is the default; raise it up to 10 for higher fidelity at the cost of file size. Pass --presigned-url instead of -o to upload the recording straight to your own bucket. Share a preview link When the work is ready for review, build with --upload so the artifact lands in Asset Storage, then post the preview URL. Then share: The reviewer opens it in any browser. No install, no Mac. Delete the instance With no ID, this deletes the last instance the CLI created (tracked in ~/.lim/last-instances.json). If an agent created several instances in one session, list them by label first: Watch out for these - element-tree is big. Pipe through grep or jq so it doesn't fill your agent's context window. - Build errors are the agent's job. If lim xcode build fails, the agent reads the output, fixes the code, and rebuilds. It doesn't push the failure back to the user. - Don't know the bundle ID? Check the Xcode project, or run lim ios list-apps after a successful build. - lim ios delete defaults to the last instance. If you created more than one in a session, run lim ios list first. Next steps Use the per-instance MCP server instead of (or alongside) the CLI for agents that prefer tool-use over shell. Every lim xcode build flag in depth. Signing real-device IPAs. MCP-wrapped builds. --- ## MCP for controlling instances URL: /docs/agents/mcp Every Limrun instance comes with its own MCP server. Wire it into a coding agent for tight device-control loops without shelling out to the CLI. MCP for controlling instances Every iOS and Android instance Limrun creates has its own Model Context Protocol server. Three things to know up front: - One server per instance. Not a shared endpoint. The instance returns its MCP URL once it's ready (covered below). - Auth uses the per-instance token. The status.token returned alongside the MCP URL, passed as an Authorization: Bearer header. Not your org API key. - Tool surface is device control, not the full SDK. Screenshots, taps, app lifecycle, log fetch. Builds and asset uploads stay on the CLI. This page covers when to reach for MCP vs the CLI, how to get the URL, and how to wire it into Claude Code, Claude Desktop, Cursor, Codex, and custom MCP clients. When to use the CLI, MCP, or both | Aspect | lim CLI | Per-instance MCP server | |---|---|---| | Where it runs | The agent's shell | The agent's MCP client transport | | Auth | Org API key in env or config | Per-instance status.token as Authorization: Bearer | | Connection | Fresh WebSocket per command, or a persistent one via lim session start | Long-lived MCP session | | What it can do | Everything Limrun does. Full CLI reference. | Device control only. Five tools, listed below. No builds, no asset uploads. | | Best for | Builds, instance lifecycle, asset management | Tight tap-screenshot-element-tree loops where round-trips matter | Most agents end up using both. Use the CLI for provisioning, builds, and assets. Use MCP when the agent needs native tool-use over the device-control surface without shelling out. Get the MCP URL and token Create an instance, then read mcpUrl and token off status. Both come back on the same response; you need both to talk to the MCP server. Or from the CLI: The URL itself has no credentials in it. Authenticate every MCP request by passing the per-instance status.token as an Authorization: Bearer header. The token is scoped to this one instance: it works only while the instance is running, and it can't be used for anything outside it. Do not use your org API key here; that one is for the control plane (creating, listing, deleting instances). Android instances expose mcpUrl the same way. If you already have an instance running, the console at console.limrun.com gives copy-paste snippets for each supported agent. Open a running instance and click the connect icon in the sidebar. Wire it into your agent Claude Code Replace ` and with the two values from the create response above. Verify with claude mcp list. The console renders the same command with your real values filled in, ready to copy: Claude Desktop Add the URL to your MCP servers config in Claude Desktop's settings: Cursor The console's "Add to Cursor" button opens Cursor with the MCP server pre-filled. To wire it by hand, add a new MCP server in Cursor's settings with the URL and the Authorization: Bearer header. Codex Web or any custom MCP client Any MCP client that speaks HTTP transport works. Pass the URL as the endpoint and Authorization: Bearer as a header. The Connect modal's Custom section gives both values pre-populated: What the MCP server exposes Five tools. | Tool | What it does | |---|---| | ios-screenshot-and-element-tree | Returns the current screenshot and the accessibility element tree in one call. The agent should call this before every action to see the current state. | | ios-use | Performs a batch of actions in order. Action kinds: tap, tapElement, setElementValue, incrementElement, decrementElement, type, pressKey, scroll, wait, toggleKeyboard, openUrl, setOrientation, touchDown, touchMove, touchUp, keyDown, keyUp, buttonDown, buttonUp, deviceInfo, startRecording, stopRecording, discoverStoreKitConfig, clearStoreKitConfig, softReset. | | ios-open-url | Opens a URL or deep link in the simulator. Use this for Safari URLs and for opening apps via custom schemes (including exp:// for Expo Go). | | ios-app | Lists installed apps and launches or terminates them. Does not install apps. Use the CLI or SDK for installs. | | ios-logs | Fetches the last N combined stdout/stderr log lines for a given bundle ID. | The actions inside ios-use map onto methods on the SDK's createInstanceClient. The MCP server batches them into a few tools rather than exposing one tool per action so the agent can group related steps into a single round-trip. App installs, Xcode builds, and asset uploads aren't on the MCP server. The MCP protocol doesn't give a tool access to the client's filesystem, so anything that needs to send bytes from your machine (an APK, a .app` bundle, a build artifact) lives on the CLI and SDK instead. Next steps The full device-control surface MCP tools call into. Every method, every option. Use the CLI for instance lifecycle, builds, and asset management. MCP doesn't cover those. --- ## Run an Android Emulator URL: /docs/android/run-emulator Spin up a cloud Android emulator. Install APKs, connect with ADB or control it with LIM SDK. Run an Android Emulator Limrun spins up real Android emulators in the cloud. Drive them from the LIM SDK, or open an Android Debug Bridge (adb) tunnel and use anything that speaks adb: Android Studio, Appium, scrcpy, your own shell scripts. This page covers: 1. Starting an Android instance. 2. Connecting to it with the LIM SDK or adb. 3. Reading the screen and driving the device. Prerequisites - Install the lim CLI or one of the SDKs - Get a Limrun API key from the Limrun console and export it. The CLI and every SDK read this variable. - (Optional) Install adb, if you plan to tunnel. The Android Studio "Command-line tools" install ships it. On macOS, brew install android-platform-tools. SDK capability matrix The three SDKs cover different slices of the Android surface. TypeScript ships the full device-control client; from Python and Go, drive the device over the adb tunnel. | Capability | TypeScript | Python | Go | |---|---|---|---| | Instance CRUD | ✓ | ✓ | ✓ | | Asset CRUD | ✓ | ✓ | ✓ | | assets.getOrUpload (local file uploader) | ✓ | upload manually | ✓ | | Provision Playwright-Android sub-sandbox | ✓ | ✓ | ✓ | | Device-control client | ✓ | not exposed | not exposed | | Screenshot, element tree | ✓ | use adb over the tunnel | use adb over the tunnel | | Tap, type, scroll, open URL | ✓ | use adb over the tunnel | use adb over the tunnel | | Video recording (startRecording / stopRecording) | ✓ | use adb screenrecord | use adb screenrecord | | APK install | ✓ | use adb install | use adb install | | Connection lifecycle | ✓ | not exposed | not exposed | Provision an instance With the CLI or SDK set up and authenticated, provisioning is a simple command: Create parameters Flags for lim android create: | Flag | Use this to | |---|---| | --region | Pin the region (e.g. us-west). Otherwise scheduled by availability. | | --display-name | Set the human-readable name shown in the console. | | --label key=value | Attach a metadata label. Repeat for multiple labels. | | --reuse-if-exists | Return the existing instance with matching labels + region instead of creating a new pod. Defaults to false. | | --inactivity-timeout | Inactivity timeout (1m, 10m, 3h). Defaults to the organization setting. | | --hard-timeout | Forced termination after this wall-clock window. Defaults to no cap. | | --install | Pre-install a local APK while provisioning. Repeatable. See Pre-install APKs. | | --install-asset | Pre-install an APK that's already in Asset Storage. Repeatable. See Pre-install APKs. | | --rm | Auto-delete the pod when the CLI process exits. Defaults to false. | | --connect / --no-connect | Open the ADB tunnel after the instance is ready. Defaults to true. | | --adb-path | Point the CLI at a non-default adb binary. Defaults to adb (looked up on PATH). | spec.clues and spec.sandbox.playwrightAndroid are SDK-only. | Field | Type | Meaning | |---|---|---| | wait | boolean | When true, the call returns only after status.state === 'ready'. Without it, you'll need to poll. Defaults to false. | | reuseIfExists | boolean | When true, return the existing instance with matching metadata.labels and spec.region instead of creating a new pod. Defaults to false. | | metadata.displayName | string | Human-readable name surfaced in the web console alongside metadata.id. | | metadata.labels | Record | Free-form key=value map for tagging and grouping. | | spec.region | string | Pin region (e.g. us-west). When omitted, the scheduler picks based on clues plus availability. | | spec.inactivityTimeout | duration string | 1m, 10m, 3h, etc. Defaults to 3m. Passing '0' defers to the organization-level default. | | spec.hardTimeout | duration string | Forced termination after this wall-clock window. '0' means no cap; the pod runs until inactivity or explicit delete. Defaults to 0. | | spec.clues[].kind | 'ClientIP' \| 'OSVersion' | Scheduling hint: bias by OS version or by user location. | | spec.clues[].clientIp | string | Required when kind: 'ClientIP'. Set this to the IP of the end user whose browser will stream the emulator. The scheduler geolocates this IP and provisions in the nearest region for low-latency streaming. spec.region overrides this clue. | | spec.clues[].osVersion | string | Required when kind: 'OSVersion'. One of '13', '14', '15'. | | spec.initialAssets[] | array of InitialAsset | APKs to install while provisioning. See Pre-install APKs. | | spec.sandbox.playwrightAndroid.enabled | boolean | Provision a Playwright-Android sub-sandbox alongside the emulator and surface its CDP URL on status.sandbox.playwrightAndroid.url. Defaults to false. See Automated testing > Playwright. | Pre-install APKs Set initialAssets at provisioning time to install one or more APKs while the pod boots. Each APK must come from a publicly reachable HTTPS URL (a GitHub release asset, an S3 presigned URL) or Limrun's Asset Storage. For local files, the CLI's --install and the TypeScript/Go SDK's assets.getOrUpload({ path }) upload to Asset Storage first and hand the daemon a signed URL. Python's SDK doesn't ship that helper. Upload manually with assets.getorcreate plus a PUT to the returned upload URL before calling create. See Asset Storage. Using Split APKs If you ship your app as split APKs, ie a base.apk plus a few config.* APKs (config.xxhdpi.apk for screen density, config.en.apk for locale, config.arm64_v8a.apk for architecture, etc.), the whole set has to install as one atomic group or the app won't run. Pass them together by switching source to AssetNames or URLs and giving the entry an array. The first filename is treated as the base APK; the rest are config APKs. Split APKs are SDK-only. Each CLI --install / --install-asset becomes its own initialAssets[] entry, so the set installs as separate apps rather than a grouped install. Use an SDK to keep them in one entry. Use separate entries in initialAssets when you want to install unrelated apps in sequence; everything inside one entry is treated as a single grouped install. Set up custom Chrome Flags You can use Android's local Chrome's experimental features by turning on a specific flag. This is SDK-only. To do this, add an entry to initialAssets array with kind: 'Configuration': Anything you want to upload once and reuse goes through Asset Storage first. For builds you want to push to an already-running pod, see Install an APK. Example response A successful create returns the full instance resource. Watch the livestream - To watch the emulator stream, open signedStreamUrl in a browser. No need to login or get an API key. - For an embedded experience inside your own app, see Embed the simulator. Field reference | Field | Use | |---|---| | metadata.id | Stable instance identifier. Pass to get, delete, connect. | | metadata.labels | Echoed back. Drives reuseIfExists matching and label-selector listing. | | spec.region | Region the scheduler placed the pod in. | | status.state | unknown, creating, assigned, ready, terminated. | | status.apiUrl | HTTP base for the Android control daemon. WebSocket control derives from it (apiUrl + /ws). Pass to createInstanceClient. | | status.adbWebSocketUrl | WebSocket endpoint for the ADB tunnel. Pass to createInstanceClient as adbUrl. | | status.endpointWebSocketUrl | WebSocket endpoint for the ` web component (append ?token=). | | status.signedStreamUrl | Console-hosted watch URL with the token baked in. Open in a browser without your API key. | | status.mcpUrl | Per-instance MCP HTTP endpoint. Authenticate with your org API key, not status.token. See MCP. | | status.targetHttpPortUrlPrefix | WebSocket prefix (wss://...) for reaching HTTP services running on the device. Append your target port to forward traffic through. | | status.sandbox.playwrightAndroid.url | Chrome DevTools Protocol URL when the Playwright sub-sandbox is enabled. | | status.token | Per-instance bearer for apiUrl, adbWebSocketUrl, and endpointWebSocketUrl. Embedded in signedStreamUrl. | Connect to your instance Two ways to drive the device: 1. LIM SDK. Opens a typed WebSocket session to the Android control daemon. Tight, ergonomic surface for the common moves (tap by selector, screenshot, element tree, record). 2. ADB tunnel. Exposes the emulator's adb socket as a local TCP listener. Anything that speaks adb attaches as if it were a USB device. Use the SDK when you want strongly-typed results and built-in selectors. Use the ADB tunnel when you need the long tail of Android tooling (logcat, file push/pull, debugger attach, scrcpy, Appium) or you're calling from Go or Python. | Capability | LIM SDK | ADB tunnel | |---|---|---| | Screenshot | client.screenshot() returns a base64 PNG data URI | adb shell screencap -p > screen.png | | Read UI hierarchy | client.getElementTree() returns AndroidElementNode[] plus raw XML | adb shell uiautomator dump, then parse /sdcard/window_dump.xml | | Find element by selector | client.findElement(selector, limit?) with typed AndroidSelector | dump the tree, then grep/parse XML manually | | Tap by selector | client.tap({ selector }) (server-side lookup + tap) | dump tree, parse bounds, then adb shell input tap X Y | | Tap by coordinates | client.tap({ x, y }) | adb shell input tap X Y | | Type text | client.setText(target?, 'hello') | adb shell input text 'hello' | | Press key (with modifiers) | client.pressKey('HOME', ['shift']) | adb shell input keyevent KEYCODE_HOME (no modifier syntax) | | Scroll | client.scrollScreen('down', 6) / scrollElement(target, ...) | adb shell input swipe X1 Y1 X2 Y2 | | Open URL / deeplink | client.openUrl('myapp://...') | adb shell am start -a android.intent.action.VIEW -d | | Record video | client.startRecording / stopRecording with localPath / presignedUrl sinks | adb shell screenrecord /sdcard/out.mp4 (3-minute cap per file), then adb pull | | Install APK from HTTPS URL | client.sendAsset(url) (daemon downloads on the device side) | download locally, then adb install ./out.apk | | Install APK from local file | assets.getOrUpload({ path }) then sendAsset(asset.signedDownloadUrl) | adb install ./build.apk | | Launch / force-stop app | not exposed | adb shell am start -n pkg/.Activity / adb shell am force-stop pkg | | Logcat | not exposed | adb logcat | | File push / pull | not exposed | adb push local /sdcard/... / adb pull /sdcard/... | | Run arbitrary shell | not exposed | adb shell | | Mirror the device UI | use signedStreamUrl in a browser or | scrcpy -s 127.0.0.1: | | Attach Android Studio / IDE | not applicable | pair Device Manager with 127.0.0.1: | | Drive with Appium | not applicable | point appium:udid at 127.0.0.1: | | Connection lifecycle | typed getConnectionState / onConnectionStateChange with auto-reconnect | adb-server's built-in retry; adb reconnect if it loses the socket | | Languages | TypeScript (device control + tunnel); Go and Python: tunnel only | any language that can shell out to adb | Create a client for the LIM SDK createInstanceClient opens a WebSocket to the Android control daemon. Every method on the returned client is one round-trip over that socket. You'll need two fields from the create response: - status.apiUrl. HTTP base for the daemon; the WebSocket URL is derived from it. - status.token. Per-instance bearer. | Option | What it controls | |---|---| | apiUrl | HTTP base for the Android control daemon. The WebSocket endpoint is derived as apiUrl + /ws. Recording downloads use the same base. Required. | | token | Per-instance bearer sent as Authorization: Bearer on the WebSocket handshake. Required. | | logLevel | One of 'none' \| 'error' \| 'warn' \| 'info' \| 'debug'. Logs are prefixed [Endpoint]. Defaults to info. | | maxReconnectAttempts | Max consecutive reconnect attempts after a transient WebSocket drop before the client gives up. Defaults to 6. | | reconnectDelay | Initial backoff between reconnect attempts. Each attempt doubles, capped at maxReconnectDelay. Defaults to 1000 ms. | | maxReconnectDelay | Upper bound on the exponential backoff window. Defaults to 30000 ms. | The Go and Python SDKs don't currently expose a device-control client; from those languages, drive the device through the ADB tunnel or call the REST surface directly. Start an ADB tunnel lim android connect opens a long-lived WebSocket between your machine and the emulator's adb socket, binds it to a random 127.0.0.1 port, and runs adb connect 127.0.0.1: for you. The tunnel stays up until you Ctrl+C. The emulator then shows up under adb devices like any networked device. In another terminal, verify it's wired up and run anything adb understands: Some tools that can work with adb: - Android Studio. Open Device Manager → Pair Device Using Wi-Fi, point it at 127.0.0.1:, and the emulator appears in the device picker. Run, debug, and Logcat flow through it. - scrcpy. Run scrcpy -s 127.0.0.1: to mirror and remote-control the device. - Appium. Set appium:udid (or appium:deviceName) to 127.0.0.1: on any upstream Appium server. See Automated testing > Appium. Read the screen Three primitives for inspecting the device using the SDK. All of these require an initiated client: - Take a screenshot - Capture screenrecordings - Get element tree in a screen Take a screenshot screenshot captures the current frame as a base64-encoded PNG. The CLI writes the decoded bytes to a path you supply; the SDK returns a data URI that you decode yourself. Record the screen Recording is server-side and writes to the emulator's filesystem. Start a recording, drive the UI, then stop. The output is H.264 in an mp4 container. quality accepts the integers 5 through 10. Higher values increase bitrate and file size. On the SDK, pick the sink that matches where the file should live: - Local file. stopRecording({ localPath }). The server returns the file; the SDK streams it to localPath before resolving. - Your bucket. stopRecording({ presignedUrl }). The server PUTs directly to your URL; the SDK only waits for that upload. - Just the URL. stopRecording({}). Resolves with the download URL; fetch the bytes later with Authorization: Bearer ${status.token}. Get the element tree Sample raw tree.xml output: Sample tree.nodes output: Control the device Input actions need a way to specify what to act on. That shape is AndroidElementTarget, which carries either a selector or explicit coordinates. Prefer selectors. Layouts shift between OS versions; coordinates don't survive that. Install an APK sendAsset(url) tells the daemon to download an APK and install it. The URL has to be publicly reachable (a GitHub release asset, an S3 presigned URL) or come from Limrun's Asset Storage. The CLI's lim android install-app accepts either a path or a URL: paths are uploaded to Asset Storage first (MD5-deduplicated), URLs are passed straight to the daemon. From TypeScript / Go, upload local files with assets.getOrUpload({ path }) and pass the returned signedDownloadUrl to sendAsset. Python users upload manually with assets.getorcreate plus a PUT before calling sendAsset. See Asset Storage. Select an element findElement queries the tree without you having to parse it. Every non-empty selector field is ANDed together. Selector fields (SDK key on the left, equivalent CLI flag on the right): | SDK field | CLI flag | Matches | |---|---|---| | resourceId | --resource-id | android:id/... resource string. Most stable selector. | | text | --text | Visible text. Case-sensitive exact match. | | contentDesc | --content-desc | Accessibility content description. | | className | --class-name | Widget class (android.widget.Button, androidx.viewpager.widget.ViewPager, etc.). | | packageName | --package-name | Owning app package. Useful to scope queries when the launcher is in front. | | index | --index | Sibling index. | | clickable | --clickable / --no-clickable | Filter to clickable nodes only. | | enabled | --enabled / --no-enabled | Skip disabled controls. | | focused | --focused / --no-focused | Find the currently focused control. | | boundsContains | --bounds-contains-x --bounds-contains-y | Find the deepest node whose bounds contain that pixel. | Tap, type, or press pressKey accepts plain names (BACK, ENTER, A, TAB), digit strings ('4'), or fully-qualified KEYCODE_* constants. The response echoes the resolved keycode so you can confirm what fired. Scroll scrollScreen performs a finger-swipe gesture from the screen center. scrollElement (CLI: pass selector flags to lim android scroll) scrolls inside a specific scrollable node such as a list, ViewPager, or ScrollView. Both take a direction and an amount in Android scroll units. Valid directions: 'up' | 'down' | 'left' | 'right'. The SDK call resolves with the gesture's start and end points so you can sanity-check what landed on the wire: The SDK defaults amount to 6 (small flick); the CLI defaults to 300 (a hefty scroll). The field on the wire is the same; pass the same number to both for identical behavior. Open URLs openUrl resolves the intent the same way am start -a android.intent.action.VIEW -d would. It works for web URLs and registered deeplink schemes. If no installed activity matches the intent, the daemon rejects with startActivity returned code -91 (Android's ActivityManager.STARTINTENTNOT_RESOLVED). This catches unregistered custom schemes (myapp://...) and standard schemes whose handler isn't on the base image. On the stock image, mailto: fails (no mail app), while tel: and https:// succeed (dialer + Chrome are present). Install the missing handler via Pre-install APKs at boot, or Install an APK on a running pod. Manage the connection The TypeScript client exposes lifecycle hooks for long-running drivers and connection debugging. Keep-alive The client auto-pings the WebSocket every 30 seconds and surfaces state changes through a callback. Use keepAlive for an explicit application-level ping (during a long idle stretch, before a known-flaky network transition), and getConnectionState / onConnectionStateChange to observe. The SDK auto-reconnects on transient failures with exponential backoff (defaults: 6 attempts, 1 s base, 30 s cap). Disconnect disconnect closes the WebSocket cleanly and disables auto-reconnect. It does not delete the instance; the emulator keeps running until its inactivity or hard timeout fires. Delete Each Android instance is single-tenant: only one client should drive it at a time. Scope instances per user, per PR, or per session with labels, then let reuseIfExists return the same warm pod on the next call. Tear down explicitly when you're done, or clean up by label selector across a tenant. The inactivity timeout fires if nobody talks to the daemon for that long. The hard timeout fires regardless. Set both to your tolerance for orphaned pods. See also Wire any upstream Appium server to the ADB tunnel for native or hybrid Android tests. Drive Chrome on the emulator with Playwright via the playwrightAndroid sub-sandbox. Stream the live emulator into your web app with . Upload APKs once, reuse them across instances via initialAssets`. --- ## Build with remote Xcode URL: /docs/ios/build-with-xcode Sync your source, run `xcodebuild` in the cloud, stream the logs back, and install the result on a remote simulator. Or ship a signed IPA. Build with remote Xcode Limrun lets you compile iOS apps on a remote Mac. Your laptop or CI runner doesn't need to be one. After a successful build, you either run the app on a remote simulator or download a signed IPA to ship. The flow: 1. Provision an Xcode sandbox. 2. Sync your source folder to it. 3. Run xcodebuild on the remote Mac. Logs stream back. 4. Install or ship. Successful builds auto-install on a paired simulator, or you upload the IPA to share. SDK coverage by language | Surface | TypeScript | Python | Go | CLI | |---|---|---|---|---| | Provision an Xcode instance | ✓ | ✓ | via REST | ✓ | | Sync source and run xcodebuild | ✓ | not in SDK | not in SDK | ✓ | Python and Go users provision the instance with their SDK and drive the build with lim xcode build. See the SDK capability matrix. Two ways to use the Xcode sandbox The right shape depends on whether you also need a simulator right now. Limrun lets your agent compose its environment step-by-step: get an Xcode sandbox for the build, attach a simulator only when it's time to test, drop the simulator and go back to just Xcode (or swap to Android) when you're not. You only pay for what's currently running. Future services like Blender and Unity follow the same pattern. Paired with an iOS simulator (most common) When the agent is going to test on a simulator right after the build, create one iOS instance with the Xcode sandbox attached. Pass --xcode to the CLI, or set the sandbox config on the SDK call as shown below. After a successful build, the app auto-installs and auto-launches on the simulator. On the SDK side, xcode is the handle you use in the sync and build sections below. On the CLI, the --xcode flag is what carries the sandbox attachment through. Standalone, without a simulator For pure CI builds, real-device IPA distribution, or build steps where the agent doesn't need a simulator yet, create a top-level Xcode instance. Attach a simulator later only when it's time to run the app (see Auto-install on the simulator). If you only have the URL and token from a different process (not the full instance object), open the client with the raw form: The Go SDK doesn't yet have a standalone Xcode resource. From Go, either provision an iOS instance with the Xcode sandbox attached (the paired pattern above), POST to /v1/xcode_instances directly, or call lim xcode create from your process. Sync your source Syncing pushes your source to the sandbox. The first sync uploads everything; later ones only send what changed. What gets synced The SDK skips these paths regardless of .gitignore: - .git, .DS_Store, and the basis cache directory. - Build outputs: build/, .build/, DerivedData/, Index.noindex/, ModuleCache.noindex/, .index-build/. - Dependency caches: .swiftpm/, Pods/, Carthage/Build/. - Anything under xcuserdata/ or ending in .dSYM/. On top of that, paths matched by your project's root .gitignore are skipped, with one exception: .xcconfig files always sync because Xcode needs them on the remote. If a file is both tracked by git and ignored, the SDK warns you so you can fix the mismatch. Dependency caches (Pods/, .swiftpm/, Carthage/Build/) don't need to ship: the remote sandbox detects Podfile / Package.swift / Cartfile and resolves the dependencies on its side before the build runs. Keep them in your local .gitignore as usual. To exclude something the SDK doesn't already skip, pass a regex on the CLI or a predicate on the SDK: Additional files If your build needs files that aren't in your repo (a .netrc for private package access, a CI-only Config.swift, a secrets file), pass them in alongside the sync. On the CLI, repeat --additional-file local=remote (see agents/cli). Paths on the remote side that start with ~/ expand to the sandbox's home directory. Tune the delta sync maxPatchBytes caps how big a single file's diff can grow before the SDK gives up on a diff and uploads the file in full. Default 4 MiB. - Lower it on a slow connection, where a big diff takes longer to compute and upload than just sending the whole file. - Raise it when one file routinely changes by tens of megabytes. basisCacheDir is the local copy of the last sync that makes re-syncs fast. The default lives in your OS temp directory, which CI runners wipe between jobs; pin it to something persistent like /var/cache/lim in CI. Run xcodebuild Trigger a build. Logs stream back as it runs. Build streams While the build runs, the call exposes three event-emitter channels you can subscribe to: | Channel | Carries | |---|---| | command | The full command string the sandbox executed. One event, then closes. | | stdout | xcodebuild stdout as the build runs. | | stderr | xcodebuild stderr. | Awaiting the call resolves to a result object with exitCode, status (SUCCEEDED, FAILED, or CANCELLED), and a signedDownloadUrl when you asked for an upload. Share that URL with a teammate or reviewer. Build settings Match what you'd pass to local xcodebuild: | Field | Use | |---|---| | workspace | Path to a .xcworkspace, e.g. MyApp.xcworkspace. | | project | Path to a .xcodeproj. Use this or workspace, not both. | | scheme | Required for multi-scheme apps. | | sdk | iphonesimulator (default for the simulator), iphoneos for device builds, watchsimulator / watchos for watchOS. | Sign for real-device builds To produce a signed IPA for distribution, switch the target SDK to iphoneos, pass a P12 certificate, and pass a provisioning profile. Add an upload target so the IPA lands in Asset Storage with a downloadable URL. The build result includes a signed download URL when you've configured an upload. Store it in your PR comment or pipeline output. Anyone with the URL can download the IPA without a Limrun API key. The signature expires after 15 minutes; if you need it later, re-fetch a fresh URL with lim asset list --name --download-url. Upload the build artifact Two options for distributing a build artifact. Upload to Asset Storage. The artifact lives in Limrun's managed storage and can be referenced by name in later lim ios create --install-asset calls. Asset Storage also backs the PR Previews flow. Upload to your own bucket. Pass a pre-signed S3, GCS, or R2 URL and the artifact PUTs directly to it from the sandbox. The build bytes never travel through your client; the sandbox handles the upload itself, which avoids a round-trip through whatever machine called lim xcode build. Use this when you want the artifact in storage you already manage, or when the build is large enough that pulling it back through your CI runner would be wasteful. Auto-install on the simulator When the Xcode sandbox is paired with a simulator, every successful build auto-installs and auto-launches on it. No extra step. For standalone Xcode instances, attach a simulator at any time: Or via the SDK: After the attach call returns, the next successful build auto-installs on that simulator. Re-attach to a different simulator if you want to test the same build across multiple device models. Full example: build a signed IPA on every PR A complete Linux CI job that builds, signs, uploads, and posts a download link: What this workflow does, step by step: 1. Checks out the PR's code on an Ubuntu runner. 2. Installs the Limrun CLI globally with npm. 3. Decodes the signing certificate and provisioning profile from base64-encoded GitHub secrets into the runner's filesystem. 4. Provisions an Xcode sandbox labelled with the PR number, so re-pushes to the same PR reuse the same sandbox instead of spawning new ones. 5. Runs a signed device build (--sdk iphoneos plus signing flags) and uploads the IPA to Asset Storage under a name tied to the PR number. The CLI prints the signed download URL to the runner log. No runs-on: macos-latest. No Apple-licensed CI runner. The build happens on Limrun's Mac fleet; the runner only needs to call lim. Next steps Drive the simulator the build just installed onto: taps, screenshots, recordings, logs. Wire lim xcode build --upload into a GitHub workflow that posts a preview link on every PR. Manage uploaded build artifacts, pre-install apps at boot, and browse the Limrun App Store. Auth, errors, the instance state machine, every resource × CRUD. --- ## Test In-App Purchases URL: /docs/ios/in-app-purchases Test in-app purchases on a remote iOS simulator using StoreKit's local test environment. Register a `.storekit` config, or let Limrun snapshot one from your App Store Connect sandbox products. Test In-App Purchases StoreKit can serve in-app purchase products from a local .storekit configuration file instead of from App Store Connect. Limrun's iOS instances expose this local test environment through three SDK calls: register a config, auto-generate one from your sandbox products, or clear it. Your app makes the same StoreKit calls it does in production; the simulator answers them from your file. Before you start - A Limrun API key in LIMAPIKEY. If you haven't used the CLI before, walk through the Quickstart first. - An iOS app that uses StoreKit (or StoreKit 2) for in-app purchases. - Either a .storekit configuration file (the kind Xcode generates via File → New → File → StoreKit Configuration File), or a bundle ID that already has IAPs registered in App Store Connect sandbox. You pick which flow to use later on this page; both are supported. How the local test environment works storekitd is the iOS daemon that brokers every StoreKit request between your app and Apple's servers. In the local test environment, it reads products from a JSON-shaped file (a .storekit configuration) instead of contacting App Store Connect. Xcode uses this same mechanism when you run a scheme with a StoreKit config selected; Limrun exposes it remotely through three SDK methods (setStoreKitConfig, discoverStoreKitConfig, clearStoreKitConfig). Two practical consequences: - The simulator needs to be set up so it accepts a local config. That means provisioning it with the Xcode sandbox attached (the same sandbox you use for Build with remote Xcode), and ad-hoc-signing the build so the simulator runs it without an Apple Developer account. - The config is keyed by bundle ID. Setting it for com.example.MyApp doesn't affect any other bundle on the same simulator, and clearing it sends StoreKit back to real-sandbox behavior for that bundle only. Provision an instance with the Xcode sandbox Same create call you'd use for any build-and-test flow, with sandbox.xcode.enabled switched on: When the call resolves, two URLs on the returned object matter for IAP work: - The Xcode sandbox URL (instance.status.sandbox.xcode.url). The Xcode client connects here to sync source and run xcodebuild. - The device control-plane URL (instance.status.apiUrl). The iOS client connects here to call the StoreKit helpers and drive the UI. Both authenticate with the same instance.status.token. Build and install the app Sync your source to the Xcode sandbox and run xcodebuild. Critically, the build must be ad-hoc-signed so StoreKit's local test environment accepts the bundle. Simulator-target xcodebuild does ad-hoc signing by default when you don't pass certificate parameters, so you don't need to provision anything signing-related yourself. After a successful build, the app auto-installs on the attached simulator. The Xcode sync + build pipeline isn't exposed in the Python or Go SDKs today. From those languages, drive the build through lim xcode build as a subprocess. See Build with remote Xcode for the full surface (workspace, scheme, real-device signing, log streaming, artifact upload). Pick a flow The three StoreKit helpers live on the iOS device-control client: setStoreKitConfig, clearStoreKitConfig, discoverStoreKitConfig, and softReset are TypeScript-only today. The Go iOS WebSocket client at github.com/limrun-inc/go-sdk/websocket/ios ships the rest of device control (taps, element-tree, screenshots, simctl) but not these StoreKit helpers, and the Python SDK is control-plane only. From Go or Python, drive the StoreKit + soft-reset steps from a Node subprocess (or call the underlying HTTP endpoints directly); the surrounding provision, build, launch, and tap steps still work in your primary language. From here, you have two ways to populate the local config: register a file you already have, or auto-generate one from your existing App Store Connect setup. The second option, discover, leans on what you've already done in App Store Connect. When you set up an iOS app for distribution, you also register every in-app purchase there: a product ID, a price tier, localized titles and descriptions, and (for subscriptions) groups and durations. App Store Connect mirrors this data into a sandbox storefront, a parallel pre-production environment that real devices can fetch from when signed into a sandbox-tester Apple ID. Discover skips the tester sign-in: it has the simulator make one sandbox product request (when you open the paywall during a discoverStoreKitConfig call), captures the response from the StoreKit cache on the device, and turns it into a .storekit file. You get a config that exactly matches what App Store Connect has for that bundle, without authoring or maintaining a separate file. | Flow | What you need | When to use | |---|---|---| | Explicit (setStoreKitConfig) | A .storekit file on disk | You already maintain a config in Xcode, or you want the test environment to be deterministic across runs. | | Discover (discoverStoreKitConfig) | IAPs registered in App Store Connect sandbox for your bundle | You have a working App Store Connect setup but no .storekit file. Limrun captures the sandbox response and converts it for you. | These two flows aren't mutually exclusive. Discover is convenient for the first run; explicit is what most teams settle on once they want their tests reproducible. Explicit flow: upload a .storekit file If you already have a .storekit (Xcode generates one via File → New → File → StoreKit Configuration File; you can also write one by hand), read the bytes and register them against the bundle ID: setStoreKitConfig takes the raw file bytes (Buffer or Uint8Array). The simulator wires the config to bundleId immediately. The app's next StoreKit fetch will hit the local test environment instead of Apple's servers. If your app is already running, restart it so it picks up the new config: Discover flow: auto-generate from a sandbox response If you don't have a .storekit on hand but your bundle ID already has IAPs registered in App Store Connect sandbox, Limrun can read the cached sandbox response from storekitd and turn it into a .storekit for you: The call blocks server-side until either a sandbox response gets cached or the timeout fires. While it's waiting, your app needs to trigger at least one StoreKit product fetch, which usually means opening the in-app paywall. Watch the simulator via instance.status.signedStreamUrl and drive the app there yourself, or automate it with the device-control tap and element-tree APIs. If discover times out, it's almost always because either the bundle has no IAPs registered in sandbox yet, or the app didn't actually fetch products during the window. Re-running with a longer timeoutSeconds won't help in either case. After discovery succeeds, the generated config is automatically registered for the bundle, exactly as if you'd called setStoreKitConfig. You don't need a second call. Run a purchase Both flows leave the simulator in the same state: products and prices come from the local config. Drive your paywall and complete a purchase the same way you would for any other UI test: What you'll see on screen. The StoreKit purchase sheet looks identical to the production one, except the bottom of the sheet shows [Environment: Xcode] instead of [Environment: Production] or [Environment: Sandbox]. There's no Apple ID prompt, no sandbox-tester sign-in, and the confirmation step completes immediately rather than waiting on a network call to Apple. What your app code sees. From your app's perspective the purchase is real. You await the purchase call, get back a signed transaction, and the listeners and entitlement checks you've already wired up run unchanged. The only thing that differs from a real-device purchase is the certificate that signs the transaction: it's Apple's local-test certificate, not their production one. The one caveat: server-side receipt validation. If your backend verifies receipts by calling Apple's production or sandbox verifyReceipt / App Store Server API, those calls will reject the local-environment transaction because the signing certificate isn't Apple's production one. Stub the validation server out in your test environment, or short-circuit it when the build is ad-hoc-signed. State persists across app relaunches. The simulator remembers the purchase as long as the local config is active. To clear it between test cases without dropping the config, soft-reset the app's data container: To roll the whole environment back to "no local config," see Clear or replace the config. Clear or replace the config Two ways to undo setStoreKitConfig: clearStoreKitConfig is safe to call when nothing is registered, so you can put it in a test teardown without checking state first. Troubleshooting Two common causes. First, the bundle ID has no IAPs registered in App Store Connect sandbox; discover has nothing to read. Second, the app never fetched products during the wait window; open the paywall (or any screen that calls Product.products(for:) or the StoreKit 1 equivalent) while discover is running. Increasing timeoutSeconds doesn't fix either cause. The app picked up the config before the build was ad-hoc-signed, or the build was signed with a real developer team. Re-run xcodebuild without certificate parameters (server-side ad-hoc signing) and reinstall. Confirm the bundle ID on setStoreKitConfig matches your app's CFBundleIdentifier exactly. Your .storekit file is out of sync with what the app expects. Open it in Xcode (or any JSON editor) and confirm every product ID your app calls Product.products(for:) with is present, with localized prices set for the storefront the test runs in. StoreKit 2 transactions are delivered through the same channel they would be in production, but they need an active listener. Confirm your app starts an await for await transaction in Transaction.updates loop early in its lifecycle, not lazily on the paywall. Next steps The full device-control surface: taps, element queries, screenshots, app lifecycle. The build options behind xcodebuild(): schemes, signing, log streaming, IPA output. Ship every PR with a built app and a live preview link from GitHub Actions. --- ## Automatic PR Previews with GitHub Actions URL: /docs/ios/pr-previews On every pull request, build the iOS app on Limrun, upload it to Asset Storage, and post a live preview link as a PR comment. Automatic PR Previews with GitHub Actions An iOS PR preview works like a web deploy preview: every pull request gets its own build and its own link, and reviewers open the build in a browser. Limrun does the build, the asset upload, and the preview URL. GitHub Actions handles the workflow trigger and the PR comment. What you get on the PR After the workflow runs, the PR gets a comment with a preview link: Open the link in a browser. The Limrun console resolves the asset name to a freshly provisioned simulator with that build pre-installed. Use the packaged Action (recommended) limrun-inc/ios-preview-action wraps the create, build, upload, and PR-comment steps into one. It cleans up the Xcode sandbox in a post: step automatically, so you don't need a separate cleanup job. Set LIMAPIKEY in Settings → Secrets and variables → Actions on the GitHub repo, then drop this in: Inputs: project-path, project, workspace, scheme, sdk, api-key, github-token. Outputs: preview-url and asset-name. The Action posts a markdown-table comment with the platform, the short SHA, and the preview link. closed in the on.pull_request.types array triggers the post-step cleanup when the PR merges or closes. Custom workflow Write the workflow by hand when you need finer control over the comment format, an asset-naming scheme different from the Action's default, or a runner outside GitHub Actions. The CLI is plain Node, so the same lim xcode build --upload pattern works on GitLab CI, CircleCI, Buildkite, Jenkins, and anywhere else npm i -g @limrun/cli runs. End-to-end: build on every PR open or sync, upload under a PR-scoped asset name, post the preview link as a comment. Replace MyApp with your scheme name. What each step does 1. Checkout. The source tree is what gets synced to the Xcode sandbox. 2. Install Limrun CLI. npm i -g @limrun/cli runs anywhere Node does. The build itself runs on Limrun's Mac fleet, so the job stays on a cheap Linux runner. 3. Build on Limrun. lim xcode create --reuse-if-exists provisions (or reuses) a sandbox tagged with the PR number and repo name, so subsequent pushes to the same PR reuse the same sandbox instead of spinning up a fresh one. lim xcode build . --upload then syncs the source, runs xcodebuild, and uploads the artifact to Asset Storage. Naming the asset after the PR means the next push overwrites it, so the preview link always points at the latest commit. 4. Comment with preview link. Build https://console.limrun.com/preview?asset=&platform=ios and post it as a PR comment. Opening the link in the Limrun console provisions a simulator and pre-installs the asset uploaded in step 3. Clean up when a PR closes The packaged Action handles this in its post: step. For the custom workflow, add a cleanup job that deletes everything tagged with the closed PR: Trigger it from the same workflow file by adding closed to the on.pull_request.types array. Next steps All the lim xcode build flags. Real-device signed IPAs live there. How the uploaded build artifact is stored, accessed, and cleaned up. --- ## Run an iOS Simulator URL: /docs/ios/run-simulator Drive a running iOS simulator from code or shell: taps, typing, screenshots, recordings, app lifecycle, logs, and reverse tunnels. Run an iOS Simulator Your iOS instance is ready. For provisioning, see the Quickstart; for builds, see Build with remote Xcode. Connect to your instance Open a WebSocket client to drive the instance. - TypeScript covers the full device-control surface (every method on this page). - Go covers most of the same methods. See the SDK capability matrix for the exact list. - Python only ships the control plane (create, list, delete instances). For taps, screenshots, and other device actions from Python, drive the device through the CLI or raw REST. Or from the CLI: The TypeScript and Go clients initialize differently: - TypeScript: Ios.createInstanceClient(...) returns once the WebSocket is open and client.deviceInfo is populated. - Go: iosws.NewClient(...) returns immediately; the first device call blocks until the WebSocket opens. Where the URLs come from The instance status carries everything you need: | Status field | Use | |---|---| | apiUrl | HTTP base for the device daemon. Pass to the control client. | | token | Per-instance bearer for apiUrl, endpointWebSocketUrl, and sandbox.xcode.url. Embedded in signedStreamUrl. | | endpointWebSocketUrl | WebSocket endpoint for the ` web component (pass with ?token=). | | signedStreamUrl | Console-hosted watch URL with the token embedded; open in a browser without your API key. | | mcpUrl | Per-instance MCP HTTP endpoint. Auth with the per-instance status.token, not your org API key. See MCP. | | sandbox.xcode.url | Xcode sandbox API URL (only when spec.sandbox.xcode.enabled: true). | | targetHttpPortUrlPrefix | Reverse-proxy prefix for tunneled HTTP ports on the device. | | state | unknown → creating → assigned → ready → terminated. | Read the screen Before any action, read what's actually on the screen. The element tree is the source of truth for what's interactable; the screenshot is the fastest visual sanity check. Or from the CLI: Screenshot dimensions are in points, the same coordinate space as tap(x, y). If you want to tap based on a coordinate in a screenshot's pixel space (e.g. when the screenshot was scaled), use tapWithScreenSize: Tap Prefer accessibility selectors. They survive layout changes; coordinates don't. Or from the CLI: AccessibilitySelector accepts AXUniqueId, AXLabel, AXLabelContains, type, title, titleContains, and AXValue (TypeScript field names; Go uses AccessibilityID, Label, LabelContains, ElementType, Title, TitleContains, and Value for the same fields). All non-empty fields must match (AND, not OR). Steppers, sliders, and text values For controls that aren't tapped but adjusted, use the element-targeted variants: Type and press keys Type into the currently focused field, press individual keys with optional modifiers, or toggle the on-screen keyboard. toggleKeyboard is the equivalent of pressing ⌘K in the simulator. Or from the CLI: Scroll and orientation scroll is a finger-swipe gesture. pixels is the gesture distance in points; coordinate (TS only) defaults to the screen center; momentum (0.0-1.0) controls fling decay. setOrientation flips the device between portrait and landscape. Or from the CLI: Open URLs and deep links openUrl works for both web URLs and registered deep-link schemes. Or from the CLI: Run a batch of actions When a sequence shouldn't have a round-trip between each step, use performActions. The server runs the whole batch inside the pod, stops on the first failure, and reports per-action results. This is the pattern agents reach for once they're driving anything more than a single tap. Or from the CLI: Action type values: - High-level (one-to-one with the single-action methods): tap, tapElement, incrementElement, decrementElement, setElementValue, typeText, pressKey, scroll, toggleKeyboard, openUrl, setOrientation, wait. - Raw HID (for custom gestures): touchDown, touchMove, touchUp, keyDown, keyUp, buttonDown, buttonUp. Hardware button values: home, lock, side, applePay, softwareKeyboard. Multi-touch and custom gestures The raw HID primitives let you build gestures with precise timing. Pair touchDown + touchMove + touchUp with wait actions to simulate long-presses, flings with custom inertia, or anything the higher-level scroll doesn't cover: Install, launch, and terminate apps Install from a URL or local file, launch by bundle ID, terminate when you're done. listApps enumerates what's already on the device, system apps included. Or from the CLI: Reset app data between runs Between test cases or scenario reruns, wipe the app's data container and relaunch it. Faster than uninstalling and reinstalling. Read app logs Tail the last N lines once, or stream live. The stream emits one line event per log line (batched ~500ms server-side), plus error and close. Or from the CLI: Record a video Recordings run server-side. quality accepts the integers 5 through 10; the server default is 5. Save to a local file, hand the bytes to a presigned URL, or both. Or from the CLI: Reverse-tunnel a local service Expose a TCP service on your machine to the simulator. Use it for anything the app inside the simulator needs to call into: a mock backend, a debug proxy, a test runner, or a local Expo dev server so a React Native app on the remote simulator can hot-reload from your laptop without a public tunnel. Or from the CLI: The remote port must be in 57090-57099. That range is reserved for tunneled services. Tooling that picks its own port (the canonical expo start CLI defaults to 8081, for example) needs the port made configurable before the tunnel works; the Expo PR-preview Action wraps full app builds instead of dev-server hot-reload, so it doesn't go through this path. Drop into macOS dev tools When the high-level API doesn't cover what you need, the instance exposes a small set of macOS dev tools as escape hatches. Or from the CLI: Reuse and clean up instances Each iOS instance is single-tenant. Only one client should drive it at a time, so scope instances per user, per PR, or per session with labels. reuseIfExists returns the same instance on the next call with the same labels, which keeps a long-running agent on one warm pod instead of churning through fresh ones. The pattern matters when a platform integrator triggers create from a UI action: without reuseIfExists, a tight loop on the user's side fans out into hundreds of instances (and a real-world bill). With it, repeated clicks settle on the same warm pod. Tear down explicitly when you're done: Or clean up by label selector: The console's Instances tab shows the same data in a UI. Filter by state, filter by label selector, sort by duration or created-at, and re-attach to any ready instance with one click: After a delete (or after the inactivity timeout fires), the row disappears from the Ready filter. Switching the filter to All shows the same instance in terminated state until it ages out: Configure the instance By default, instances are an iPhone in a nearby region with a server-applied inactivity timeout. To pick a different model, pin a region, pre-install an app, or attach an Xcode build sandbox, pass spec.* at create time: Or from the CLI: spec reference | Field | Type | Meaning | |---|---|---| | model | 'iphone' \| 'ipad' \| 'watch' | Apple Simulator model. Defaults to iphone. | | region | string | Pin region (e.g. us-west). Otherwise scheduled by clues + availability. | | inactivityTimeout | duration string | 1m, 10m, 3h. Default comes from your org settings. Passing 0 is equivalent to omitting it. | | hardTimeout | duration string | Forced termination. Default 0 = no hard cap. | | clues | array | Scheduling hints. { kind: 'ClientIP', clientIp } picks a region close to the end user. | | initialAssets | array | Apps to install at boot. See Asset Storage. | | sandbox.xcode.enabled | boolean | Attach an Xcode build sandbox. See Build with remote Xcode. | metadata.labels is free-form key=value and powers reuseIfExists, listing, and cleanup queries. For platform integrators: pass the end user's IP as a ClientIP clue (clues: [{ kind: 'ClientIP', clientIp: '203.0.113.42' }]). The scheduler places the instance in a region close to that IP, which is the difference between a snappy embedded simulator and a laggy one across continents. Next steps Produce the build that gets installed onto the simulator. Ship every PR with a built app and a live preview link, straight from a GitHub workflow. Render the simulator in your web app with `. StoreKit local testing without an Apple Account or sandbox tester (advanced). --- ## Asset Storage URL: /docs/platform/asset-storage Limrun's managed binary store. Pre-install your customers' apps at instance boot and hot-deploy new builds into running embedded sessions Asset Storage Asset Storage is the binary store that backs every Limrun instance. It lets you: 1. Pre-install an app build (Android, iOS, iPadOS or watchOS) at boot so the embedded simulator starts with the app ready. 2. Hot-deploy a fresh build into a running embedded session on rebuilds. For how to connect platform users to those instances, see Embed the simulator. What are Assets? An asset is a single installable binary, such as an iOS simulator build (.app, .app.zip, or .app.tar.gz) or an Android APK (.apk). | Field | Meaning | |---|---| | id | Stable auto-generated identifier. | | name | Customizable identifier. Names need to be unique globally in your org. To organise for multi-tenancy, use prefixes. | | displayName | Optional human-readable label surfaced in the console; falls back to name. | | md5 | Set once the bytes have been uploaded. Unique hash used by getOrUpload to skip re-uploads for duplicates and by installApp to enable server-side caching. | | os | Limits which platform can install the asset. Useful when the same logical name (e.g. acme/my-app-v1.2.3) covers both an iOS sim build and an Android APK. | | signedDownloadUrl / signedUploadUrl | Time-limited URLs. Only returned when you ask for them on list/get, or always on getOrCreate. | Configuration entries on initialAssets Stored assets are always binaries. That's the only thing the Asset record models, and there's no kind field on it. But spec.initialAssets accepts a second flavour of entry, kind: 'Configuration', which carries an instance-level setting instead of pointing at Asset Storage. The kind field lives on the initialAsset shape, not on the Asset record. The only configuration today is ChromeFlag, used to enable Chrome's command-line on non-rooted Android emulators so Playwright can attach. SDK-only: @limrun/cli doesn't expose a --chrome-flag flag, so this entry has to come from the SDK directly. A kind: 'Configuration' entry has no MD5, no signed URLs, and no assets.list representation. It exists only on the instance spec. The rest of this page is about kind: 'App' entries, which are the ones actually backed by Asset Storage. Upload an asset Two paths: - Use getOrUpload for CI pipelines and hot-reloading of apps to instances. - Use getOrCreate when you need a signedUploadUrl for an untrusted client browser to do the upload. getOrUpload getOrUpload computes the local file's MD5 hash, checks Asset Storage for an existing entry with the same name + same MD5, and skips re-upload if it matches. This helper ships in TypeScript and Go SDKs. Python users have to do the two steps manually with getOrCreate (shown in the next subsection). If the file's MD5 already matches an existing upload, getOrUpload returns immediately with the existing asset's URLs and no bytes are transferred. For example, Limrun's automatic PR preview GitHub Action uses it like this: (The action itself rolls the build and upload into a single lim xcode build --upload "preview/…" step (see Build with remote Xcode), but the naming convention is the same.) getOrCreate When you need finer control, for example for uploading from a worker, from a different host, from a streaming source, or letting the end user's browser PUT directly to the storage URL, use the lower-level getOrCreate and PUT to the returned signedUploadUrl yourself. For browser-direct uploads, generate the signedUploadUrl on your backend (where the API key lives) and hand only the URL to the browser. The browser PUTs to it without ever touching your LIMAPIKEY or proxying bytes through your servers. Useful when end users on your platform upload their own builds. Install an asset Two install paths: - Pre-install at instance boot. - Push into an already-running instance. Pre-install on boot Reference your asset as an entry on the spec.initialAssets array during the provisioning call. This will start the simulator with the app already on the home screen. Each entry must resolve to a binary the pod can fetch: either an existing Asset Storage entry (by name or ID), or a publicly reachable HTTPS URL such as a GitHub release asset, an S3 presigned URL, or anything else the cloud can reach. URLs on your LAN won't work. IosInstanceCreateParams.Spec.InitialAsset shape: | Field | Type | Meaning | |---|---|---| | kind | 'App' | iOS only supports App assets at boot. There is no 'Configuration' variant on iOS. | | source | 'URL' \| 'AssetName' \| 'AssetID' | How to resolve the asset. | | url / assetName / assetId | string | The matching identifier. | | launchMode | 'ForegroundIfRunning' \| 'RelaunchIfRunning' | Launch behavior post-install. Omit to install without launching. | Android's initialAsset has no launchMode field. Boot installs only. To launch an APK after the instance is ready, drive a launch from your control code (e.g. via ADB through startAdbTunnel). If your app ships as a base APK plus density / locale / ABI splits (base.apk + config.*.apk), the whole set must install as one atomic group. See Split APKs for the grouped-install shape, which applies to both pre-install on boot and mid-session install. AndroidInstanceCreateParams.Spec.InitialAsset shape: | Field | Type | Meaning | |---|---|---| | kind | 'App' \| 'Configuration' | App = APK install; Configuration = instance-level config value (Android-only; see Configuration entries on initialAssets). | | source | 'URL' \| 'URLs' \| 'AssetName' \| 'AssetNames' \| 'AssetIDs' | Singular variants install one APK. Plural variants (URLs, AssetNames, AssetIDs) install a split-APK group atomically: base APK first, config splits after. | | url / assetName / assetId | string | Singular identifier. | | urls / assetNames / assetIds | string[] | Split-APK group. The first entry is the base APK; the rest are density/locale/ABI splits. | | configuration | object | Used when kind: 'Configuration'. | For the rest of the create-call spec (regions, clues, timeouts, sandboxes), see Run an iOS simulator and Run an Android emulator. Install on a running instance When the user rebuilds while the embedded session is live, push the new asset and tell the running instance to install it. No restart, no reconnect. The URL handed to the daemon has the same reachability rule as initialAssets: it must be a publicly reachable HTTPS URL (GitHub release asset, S3 presigned URL) or come from Asset Storage. The CLI's install-app accepts either a local path (uploaded to Asset Storage first, MD5-deduplicated) or a URL. Python doesn't ship a WebSocket instance client. Run lim ios install-app from a subprocess, or call the WebSocket protocol directly. installApp accepts an AppInstallationOptions object: | Field | Type | Meaning | |---|---|---| | md5 | string | MD5 hex digest. Enables server-side install caching for remote URLs. | | timeoutMs | number | Client-side install timeout. Default 120_000 (120 s). | | launchMode | 'ForegroundIfRunning' \| 'RelaunchIfRunning' | Launch behavior after install. Omit to install without launching. | sendAsset is TypeScript-only today. From Go or Python, drive the install via lim android install-app (or push the APK through the ADB tunnel; see ADB tunnel). sendAsset(url, timeoutMs?) triggers a server-side download + pm install. The default client-side timeout is 120 s; pass a higher value if your APK is large enough that the install can run past two minutes. Split APKs If you ship your Android app as split APKs (a base.apk plus a few config.* APKs), the whole set must install as one atomic group or the app won't run. Pass them together in one initialAsset (boot) or sendAsset (mid-session) call by giving the entry an array via the plural source variants (AssetNames, URLs, AssetIDs). The first filename is treated as the base APK; the rest are config splits. The server installs them via pm install-multiple. Split APKs are SDK-only. Use separate entries in initialAssets when you want to install unrelated apps in sequence; everything inside one entry is treated as a single grouped install. Example response A successful create returns the full instance resource. The fields you'll typically reach for from the embed and install paths: state cycles through 'unknown' \| 'creating' \| 'assigned' \| 'ready' \| 'terminated'. spec.inactivityTimeout is returned as a Go duration string ("10m0s", not the "10m" you pass in). The iOS shape is identical with these differences: - iOS-only: status.sandbox.xcode.url when spec.sandbox.xcode.enabled is set. - Android-only: status.adbWebSocketUrl (WebSocket endpoint for ADB tunnel), status.sandbox.playwrightAndroid.url when the Playwright sub-sandbox is enabled. For the full field reference, see Status URL surface. Working with Expo Go Limrun maintains a small curated catalog of pre-built apps under the appstore/ name prefix. Currently, only Expo Go is available, which lets React Native users on your platform open a Snack or a local Expo project on a remote device without uploading anything. App Store asset reference: | Asset name | OS | What it is | |---|---|---| | appstore/Expo-Go-54.0.6.tar.gz | iOS | Expo Go 54.0.6 (iOS Simulator build) | | appstore/Expo-Go-55-iOS-latest.tar.gz | iOS | Expo Go 55, latest (iOS Simulator build) | | appstore/Expo-Go-54.0.6.apk | Android | Expo Go 54.0.6 (APK) | | appstore/Expo-Go-55-latest.apk | Android | Expo Go 55, latest (APK) | Install at boot the same way as one of your own assets. The appstore/ prefix is just part of the name: Pair the boot install with the ` prop to land the device on the user's Expo project automatically on first paint: That's the full integration: backend creates the instance with the Expo Go asset, frontend renders with the project URL. No Expo Go upload, no build pipeline, no app-store dance. List the available App Store entries to discover what else is curated: The appstore/ prefix is part of the stored name on the client side, but the API strips it before querying the App Store catalog server-side. That means nameFilter: 'appstore/Expo-Go-54.0.6.tar.gz' works as expected when combined with includeAppStore: true, but a partial prefix like nameFilter: 'appstor' will never match anything in the App Store, since the comparison happens after the prefix is removed. Asset organisation for multi-tenancy Asset names are global to your org, not per-tenant. The store has no built-in tenant scoping, no labels, no folders. The single primitive you have is the name itself, plus namePrefixFilter to query it. Use prefixes to divide namespaces The convention is to put the tenant (or workspace, customer, project, GitHub org/repo, PR number, anything stable) at the front of the name as a prefix: The slash isn't required, but it reads well and survives any prefix scan. Pick a separator and stick to it across your platform. Two patterns fall out of this naturally: The os field on each Asset lets you further split a tenant's namespace by platform when the same logical name covers both an iOS sim build and an Android APK: Filtering options assets.list, assets.get, and assets.delete are the operations you'll wire into your admin UI, your reaper, and your offboarding flow. Full set of filters on assets.list: | Filter | Behavior | |---|---| | nameFilter | Case-sensitive exact match on name. Cannot combine with namePrefixFilter. | | namePrefixFilter | Case-sensitive prefix match. LIKE wildcards (%, _) are treated as literal characters. Empty string is rejected with 400, so omit the param entirely if you don't want filtering. Cannot combine with nameFilter. | | includeDownloadUrl / includeUploadUrl | Set to true to have signed URLs included on each returned asset. Default is false (smaller response). | | includeAppStore | Include curated assets from the Limrun App Store, returned with the appstore/ name prefix. | | limit | Maximum number of items returned. Default 50. | The CLI maps these to --name, --download-url, --upload-url, and --include-app-store (no --name-prefix, use SDK for this). Next steps Render with initialAssets so the embedded device opens straight into the customer's app. lim xcode build --upload is the canonical way to land an iOS simulator build into Asset Storage. Wire the upload step into a GitHub workflow that posts a preview link on every PR. assets.list / getOrCreate / getOrUpload` parameters across TypeScript, Python, and Go. --- ## Embed the simulator URL: /docs/platform/embed-simulator Render a live iOS, iPadOS, watchOS, or Android device inside your web app with `` from `@limrun/ui`. Your backend keeps the API key; the browser only sees a per-instance URL and token. Embed the simulator ` from @limrun/ui streams a Limrun instance into a in your web app. It opens a WebSocket + WebRTC connection to the instance, paints the screen, forwards touch and keyboard input, and exposes imperative control through a React ref. This is the path platform integrators take: companies whose end users need a live mobile device on the page. Cloud coding agents that work on iOS, iPadOS, watchOS, or Android. Mobile preview tools. Internal QA dashboards. Same component, same wiring, on every one of those. . The same look-and-feel ships in your app." /> The Limrun console's Playground uses this exact component for its own streaming view, so what you ship to your users looks the same. Architecture overview for platforms Three parts of the system: - End user's browser. Renders `, sees only a per-instance WebSocket URL and a per-instance token. Never sees your API key. - Your backend. Holds your API key, creates the instance via the Limrun SDK, returns { webrtcUrl, token } to the browser, and handles teardown. - Limrun control plane. Provisions the instance and returns a token scoped to it. Authentication The client browser never sees your API key, it uses a WebSocket URL and a token scoped to just the one instance. This token works only as long as that instance is running, and can't be used to access anything else. Prerequisites Before you embed, you need: - A Limrun API key, kept server-side. Sign up at console.limrun.com, open Settings → API Keys, and create a token. See Authentication for the full flow. - A way to create an instance from your backend. Follow Run an iOS simulator (covers iPhone, iPad, Apple Watch) or Run an Android emulator. - A React 18+ frontend. Vite, Next.js, Create React App, Remix, etc. The single contract between your backend and is the pair endpointWebSocketUrl + token from instance.status. Every spec field, region clue, label pattern, and lifecycle knob lives on the per-platform pages above. If your platform also builds your customers' apps in the cloud (not just runs them), Build with remote Xcode is the companion page for compiling iOS apps on a remote Mac. Backend provisioning A minimal session endpoint creates an instance for an end user and returns the two strings the frontend needs. The same shape works in Fastify, Next.js route handlers, Go's net/http, or Python's FastAPI; only the framework wrapper changes. A few details worth calling out. Full coverage of each lives on Run an iOS simulator and Run an Android emulator. - Scheduling clues. Network latency between client browser and Limrun's hosting region can be minised by hinting Limrun's scheduler to provision the instance closer to the end user's location. To do this, forward the end user's IP (X-Forwarded-For or req.socket.remoteAddress) as a ClientIP clue so the instance lands near them. - Labels for tenant scope and reuse. Labels are free-form key-value pairs which can be used to organise multiple tenants or reuse hot instances. If reuseIfExists is set to true, scheduler returns existing hot instance whose region and labels match the current create call, so a user reopening their tab lands back on the same warm instance. - Pre-install the customer's app. Reference an uploaded build (or an App Store catalog entry like Expo Go) via spec.initialAssets, and the embedded simulator is launched with the app pre-installed during provisioning. See Asset Storage. - Session lifecycle. inactivityTimeout defaults to 3m, almost always too short for an embedded UI; set 10 to 30 minutes. hardTimeout is a forced-termination safety cap, default '0' (no cap). Always wire up an explicit delete on tab close. Install @limrun/ui Add the UI package to your frontend. It ships ES modules and TypeScript types; styles are bundled into the build. Works in any React 18+ tree (Vite, Next.js, Create React App, Remix). is a single forward-refed component with no providers, no context, and no global state. Render Ask your backend for a session, render the component, post a stop when the user leaves. keepalive: true on the cleanup fetch lets the request complete even if the tab is closing, so the instance terminates promptly instead of waiting for inactivityTimeout. Component props | Prop | Type | Default | Use | |---|---|---|---| | url | string | required | WebSocket URL from the instance. Pass instance.status.endpointWebSocketUrl. | | token | string | required | Per-instance bearer. Pass instance.status.token. The component appends it to the connection URL as ?token=…. | | sessionId | string | random | A unique signaling-session identifier. Prevents collisions when multiple browser tabs resolve to the same reusable instance. Use a fresh value per tab (e.g. tab-${Date.now()}). | | openUrl | string | none | A URL or deep link to open on the device once the control channel opens (e.g. https://example.com, myapp://orders/42, exp://… for Expo Go). | | showFrame | boolean | true | Draw the device bezel around the video. See iPad and Apple Watch; only iPhone and Pixel models ship native bezel art. | | autoReconnect | boolean | false | If a working session's connection drops, reconnect automatically instead of showing the Retry button. | | className | string | none | Extra class names applied on top of the component's defaults. | Pre-wired interactions The component translates browser events into device input on its own. No wiring required from your side. | Browser input | Device input | |---|---| | Mouse click / tap | Tap | | Mouse drag / single-finger touch drag | Swipe | | Alt + drag | Two-finger pinch (mirrored around the screen center) | | Two-finger touch on a touchscreen | Two-finger pinch / pan | | Letters, numbers, arrows, Enter, Backspace, modifiers | Forwarded as device key events | | Cmd + V / Ctrl + V | Reads browser clipboard, pastes into the focused field on the device | | Cmd + M / Ctrl + M | Sends the Menu key | Keyboard events only forward while the embedded element is focused. The component focuses the video on tap. iOS extended touch area iOS treats the home-indicator strip as part of the gesture surface. To make swipe-up-from-home, app-switcher, and pull-from-bottom gestures work the same way they do on a real device, exposes a 60-pixel extended touch area below the visible screen. A drag started inside that strip and moved up enters the iOS gesture system the way it would on hardware. Reconnect behavior runs its own retry budget when a connection fails: - During the initial connect, up to 3 attempts with a 1 s delay between them; if the control channel doesn't open within 15 s, the current attempt counts as failed. - If all 3 initial attempts fail, the component renders a Retry button and stops trying. The user clicking it (or your code calling ctrlRef.current.reconnect()) starts over. - If a connection succeeds, the user uses the simulator for a while, and then the connection drops: - With autoReconnect={false} (default), the Retry button is shown. - With autoReconnect={true}, the component reconnects in the background. autoReconnect={true} is the right default for long-running embedded sessions where transient network blips are expected. The Retry button is more honest for short sessions where the user benefits from knowing the connection dropped. A separate keep-alive ping fires every 10 s while the page is visible, and pauses while the tab is hidden (so an idle background tab doesn't waste your inactivityTimeout budget). Configurable actions using Pass a ref to call methods imperatively from the React side. The handle is populated once the WebSocket and control channel are open. Five methods, no more: | Method | Returns | Notes | |---|---|---| | openUrl(url: string) | void | Sends a URL/deep-link message over the WebSocket. The component URL-decodes the string before sending. | | screenshot() | Promise<{ dataUri: string }> | Rejects after 30 s if no reply. | | terminateApp(bundleId: string) | Promise | Rejects after 30 s if no reply. iOS only today. | | sendKeyEvent({ type, code, shiftKey?, altKey?, ctrlKey?, metaKey? }) | void | type is 'keydown' \| 'keyup', code is a DOM KeyboardEvent.code value (e.g. 'KeyA', 'Enter', 'ArrowDown'). | | reconnect() | void | Tears down the current WebSocket/RTC connection and starts a fresh attempt with the same url/token. | These are browser-side imperatives. If you need richer backend-side control of the same device (taps by accessibility selector, element-tree inspection, video recording, reverse tunnels, soft-resets), drive the instance from your server with Ios.createInstanceClient or the Android equivalent. The two paths are fully compatible; you can run both at once. iPad and Apple Watch showFrame ships native bezel art for iPhone (iOS), Pixel 9 (Android phone), and Pixel Tablet (Android tablet). iPad and Apple Watch don't have a dedicated frame today; render them frameless: The video stream still carries the correct aspect ratio and resolution; you can wrap the component in your own chrome if you want a frame. To pick the model on the backend side, set spec.model: 'ipad' or spec.model: 'watch' on the iOS create call. End-to-end example A self-contained backend + frontend pair. Backend creates an instance (with Expo Go pre-installed and a client-IP scheduling clue), frontend renders it and tears down on unmount. Drop these two files into a fresh project and you have a working embedded simulator. A more elaborate version of the same pattern lives in typescript-sdk/examples/fullstack, with a UI for switching platforms and iOS models. Next steps Upload customer builds and reference them in spec.initialAssets so the embedded simulator opens on their app. Backend-side device control: tap by accessibility selector, element-tree, recording, reverse tunnels. Same embed wiring, Android side. ADB tunnel, scrcpy, Appium, OS-version clues. If your customers also compile iOS apps in your product, run xcodebuild` on Limrun and pipe the result into the same simulator. --- ## Quickstart URL: /docs/quickstart Get a remote iOS simulator and Xcode build running from your terminal in 3 minutes. Quickstart Build an iOS app on a remote Xcode sandbox and watch the result stream into your browser, all from your terminal. If you're handing this off to a coding agent, read CLI for coding agents instead. That page is the skill an agent should follow end to end. Try it in your browser first If you'd rather poke at an instance before installing anything, console.limrun.com has a Playground that creates and streams an instance from a dropdown. Pick an OS, pick an app to preinstall, hit Start. The rest of this page covers the CLI flow. Set up the build Requires Node.js 20 LTS or newer. Open the console in your browser, grab the API key from the page, and write it to ~/.lim/config.yaml: On a headless host (CI, a devcontainer), set LIMAPIKEY in the environment instead. Use the sample app for the quickest path. Already have an iOS project locally? Skip the clone and cd into your project directory. A single lim ios create --xcode provisions a simulator and an attached Xcode sandbox in one call. --reuse-if-exists makes re-runs return the same instance instead of creating a new one. Open the Signed Stream URL from the output in any browser to watch the simulator live. The URL has its own token embedded, so no sign-in is needed. From inside the project directory: For multi-scheme projects, add --scheme and either --workspace or --project . Build logs stream back as they happen. On success, the build artifact auto-installs and auto-launches on the attached simulator. The streaming tab updates to your app's first screen a few seconds after the build completes. Drive the app Once the build lands, you can read state and drive the UI directly from your shell. Most commands default to the last-created instance, so you rarely need to pass --id. The element tree is the source of truth for what the UI shows: labels, accessibility IDs, types, frames. Reach for it before every action; it's faster and more reliable than visual inspection. Tear down When you're done, delete the instance. Without an ID, lim ios delete removes the last iOS instance the CLI created for you (the pointer is in ~/.lim/last-instances.json). Where to go next Hand the build, drive, and ship loop off to a coding agent via the bundled skill. Every lim xcode build flag, real-device IPAs, source-sync internals, MCP-wrapped build tools. Stream a live iOS or Android simulator into your web app with ` from @limrun/ui`. ADB tunnel, scrcpy streaming, Appium, and the rest of the Android toolchain over a remote emulator. --- ## SDK reference URL: /docs/reference Authentication, errors, resource lifecycles, and the resources × CRUD matrix in cURL, TypeScript, Python, Go, and the `lim` CLI. SDK reference The OpenAPI spec is the source of truth. Stainless generates each SDK from it, so method names and parameter shapes are consistent across languages once you account for casing conventions (camelCase in TypeScript, snake_case in Python, PascalCase in Go). Base URL Override with a per-client option in TypeScript, Python, or Go. Authentication Every request authenticates with a single API key (prefix lim_). Generate keys at console.limrun.com. The CLI and every SDK accept the key from the LIMAPIKEY environment variable by default: Credentials Two credentials show up in most workflows. Mixing them up is the most common integration bug. | Credential | Where you get it | Use for | |---|---|---| | Org API key (lim...) | console.limrun.com → Settings → API Keys; lim login; or LIMAPI_KEY | REST control plane (api.limrun.com), including create/list/delete. Also MCP: Authorization: Bearer on instance.status.mcpUrl. | | Per-instance token (instance.status.token) | Returned in status when the instance is ready | apiUrl device daemon, endpointWebSocketUrl (append ?token=), adbWebSocketUrl, sandbox.xcode.url, and SDK helpers like Ios.createInstanceClient. Embedded in signedStreamUrl for browser streaming. | The org API key must never ship to a browser or end-user client. For embedded simulators, your backend holds the API key and passes only endpointWebSocketUrl + status.token to `. See Embed the simulator. Install snippets If you'd rather skip the SDK, call the API directly with curl. No install required. SDK capability matrix The three SDKs are not equivalent. Every one covers the control plane (create / get / list / delete instances and assets). Beyond that, only the TypeScript SDK is complete; the others vary. The CLI is the easiest fallback for anything your SDK doesn't cover; raw HTTP is the universal escape hatch. | Capability | TypeScript | Python | Go | |---|---|---|---| | Instance CRUD (iOS, Android, Xcode) | ✓ | ✓ | iOS + Android only (no XcodeInstances service) | | Asset CRUD | ✓ | ✓ | ✓ | | assets.getOrUpload (MD5 dedup + signed PUT) | ✓ | upload manually | ✓ (Assets.GetOrUpload) | | iOS device control (taps, screenshot, element-tree, typeText, simctl streaming, ...) | ✓ | use REST or CLI | ✓ (subset, see below) | | Android device control over the SDK | ✓ | use REST + ADB | use REST + ADB | | ADB tunnel for Android | ✓ (startAdbTunnel) | use external adb | ✓ (tunnel.NewADB, plus Multiplexed) | | Xcode source-sync + remote xcodebuild | ✓ (xcodeInstances.createClient) | use lim xcode build | use lim xcode build | | iOS reverse tunnel | ✓ (startReverseTunnel) | not supported | not supported | | iOS app-log streaming | ✓ | not supported | not supported | | iOS video recording | ✓ | not supported | not supported | | StoreKit local-config helpers | ✓ | not supported | not supported | The Go iOS WebSocket client (github.com/limrun-inc/go-sdk/websocket/ios) covers screenshot, element-tree, tap, tap-element, type-text, press-key, launch-app, list-apps, open-url, install-app, lsof, set-orientation, increment/decrement/set element value, and simctl streaming. It does not include terminate-app, scroll, performActions, or video recording today. If you're on Python and need any of the device-control or build surfaces, the canonical option is the lim CLI from @limrun/cli (shell out from your code) or raw REST calls against the URLs returned in instance.status. The CLI is feature-complete; the SDKs catch up later. Errors Errors are returned as JSON with an HTTP status code that maps to a typed error class in every SDK. | Status | TypeScript / Python / Go | Meaning | |---|---|---| | 400 | BadRequestError | Malformed input or invalid combination of parameters. | | 401 | AuthenticationError | Missing or invalid API key. | | 403 | PermissionDeniedError | API key lacks the necessary permission. | | 404 | NotFoundError | The instance, asset, or other resource does not exist. | | 409 | ConflictError | Conflicting state (e.g. instance already terminated). | | 422 | UnprocessableEntityError | Validation error: request shape is valid but semantically rejected. | | 429 | RateLimitError | Rate limit hit. Retry with backoff. | | ≥500 | InternalServerError | Server-side issue. SDKs auto-retry by default. | | n/a | APIConnectionError | Network failure before a response. | | n/a | APIConnectionTimeoutError | Request timed out. | | n/a | APIUserAbortError | The request was aborted by the caller. | The SDKs auto-retry on 408, 409, 429, 5xx, and connection errors. The default is 2 retries with exponential backoff. Configure or disable with the per-client maxRetries option. Query params: wait and reuseIfExists Two query params show up on every create call across resources. | Param | Type | Meaning | |---|---|---| | wait | boolean | Return only after the instance reaches ready. Without wait, the call returns immediately with state: 'creating'. | | reuseIfExists | boolean | If an instance with the same (region, labels) exists, return it instead of creating a new one. | Use wait: true for interactive workflows where you need the URLs immediately. Use reuseIfExists: true whenever you want repeated calls (CLI re-runs, retries, the same PR's CI runs) to converge on the same instance. Instance state machine Every iOS, Android, and Xcode instance moves through the same lifecycle, from creating while hardware is being provisioned, through assigned while it boots, to ready when the URLs in status are usable, and finally to terminated. | State | Meaning | |---|---| | unknown | Default placeholder; should not be returned for live instances. | | creating | Provisioning. URLs in status are not yet populated. | | assigned | Hardware has been assigned; instance is booting. | | ready | Fully booted. All URLs populated. Safe to connect a control client. | | terminated | Stopped, either by explicit delete, inactivity timeout, hard timeout, or error. | instance.status.errorMessage is set when the instance terminated due to an error. Status URL surface Every successful wait: true create returns a status block with the URLs the data plane needs. | Field | iOS | Android | Xcode | Use | |---|---|---|---|---| | token | ✓ | ✓ | ✓ | Per-instance bearer for apiUrl, endpointWebSocketUrl, adbWebSocketUrl, sandbox.xcode.url; embedded in signedStreamUrl. | | apiUrl | ✓ | ✓ | ✓ | HTTP base for the per-instance device daemon (SDK / CLI control client). | | signedStreamUrl | ✓ | ✓ | | Browser watch URL with token embedded (no org API key in the browser). | | endpointWebSocketUrl | ✓ | ✓ | | WebSocket for ; pass ?token=. | | mcpUrl | ✓ | ✓ | | Per-instance MCP HTTP endpoint. Auth with org API key header, not status.token. See MCP. | | adbWebSocketUrl | | ✓ | | ADB tunnel endpoint. Pass as adbUrl to createInstanceClient. | | targetHttpPortUrlPrefix | ✓ | ✓ | | Reverse-proxy prefix for tunneled HTTP ports (used by Appium/WDA). | | sandbox.xcode.url | ✓ (conditional) | | | Xcode sandbox API URL when spec.sandbox.xcode.enabled: true. | | sandbox.playwrightAndroid.url | | ✓ (conditional) | | CDP URL when spec.sandbox.playwrightAndroid.enabled: true. | Regions and labels spec.region pins an instance to a region (e.g. eu-north1, us-east1). If omitted, Limrun schedules based on spec.clues and current availability. The console's Analytics tab breaks down runtime minutes per platform and per region, which is the canonical place to see which regions an org has used: metadata.labels is a free-form { [key: string]: string } map. The same labels do three jobs at once: they identify instances for reuseIfExists, they filter listings via labelSelector (a comma-separated key=value list), and they group instances for bulk operations like "delete everything tagged pr=42." Useful label keys for most workflows are tenant, user, session, pr, repo, agent, and managed_by. Avoid keys that might overlap with metadata Limrun adds internally. Resources × CRUD The four resources (iosInstances, androidInstances, xcodeInstances, assets) all support the same five operations. Method names follow each language's convention. Create an iOS instance List iOS instances Get / Delete an iOS instance Android instances The Android operations have the same shape as iOS. Substitute androidInstances (TS), android_instances (Python), AndroidInstances (Go), or lim android * (CLI). The Android-specific pieces are clues accepting { kind: 'OSVersion', osVersion: '13'|'14'|'15' }, the multi-asset shapes on initialAssets, and spec.sandbox.playwrightAndroid.enabled. Xcode instances The TypeScript and Python SDKs expose a top-level xcodeInstances / xcode_instances resource with the same CRUD shape as iOS. The Go SDK does not ship an XcodeInstances service today; reach the same instances through one of these paths: - Set spec.sandbox.xcode.enabled: true when creating an iOS instance and use the iOS resource on the SDK; the Xcode sandbox URL comes back at instance.status.sandbox.xcode.url. - Or call POST /v1/xcode_instances directly over HTTP. - Or shell out to lim xcode create / lim xcode build. Assets Assets use getOrCreate (upsert; named GetOrNew in Go) and getOrUpload (upsert plus actual upload) instead of create. The os field on Asset is optional; leaving it unset means the asset is available for both platforms. The getOrUpload helper that handles MD5 dedup and the signed PUT ships in the TypeScript and Go SDKs; Python callers do the two-step pattern manually (call getorcreate, then PUT the bytes to signedUploadUrl if md5 is missing or doesn't match). Auto-pagination list endpoints in TypeScript, Python, and Go return iterable objects you can range over directly. OpenAPI spec The REST API is documented on lim.run and in each SDK's generated reference (typescript-sdk/api.md, python-sdk/api.md, go-sdk/api.md). The SDKs are generated from the OpenAPI spec via Stainless. Timeouts, retries, logging All three SDKs default to a 5-minute request timeout and 2 retries with exponential backoff. Configure both per-client with the timeout and maxRetries options, or override per-request. The TypeScript SDK also accepts a logger and logLevel ('debug' | 'info' | 'warn' | 'error' | 'off'), settable through the LIMRUN_LOG env var. At 'debug' the SDK logs full HTTP requests and responses. Auth headers are redacted, but sensitive data in request and response bodies is not, so keep that in mind before turning debug logging on in production. Next steps The 3-minute path from npm install to a running simulator with your app on it. The data-plane surface that lives behind instance.status.apiUrl. ADB tunnel and Android device-control surface. Every lim` command tuned for agent workflows. --- ## Appium URL: /docs/testing/appium Drive Limrun iOS simulators and Android emulators with Appium. iOS uses a fork of the XCUITest driver; Android uses upstream Appium over an ADB tunnel. Appium Point your existing Appium suite at a Limrun instance and run it from any host, including a Linux CI runner. Your test code doesn't change; only the driver setup and the capabilities do. Before you start - A Limrun API key in LIMAPIKEY (get one from console.limrun.com). If you've never used the CLI before, walk through the Quickstart first. - Node 20 or newer to run the Appium server. - An Appium test you can already run locally. We won't cover authoring Appium tests, only pointing them at Limrun. How the integration works Each platform takes a different route to the same Appium API. iOS uses @limrun/appium-xcuitest-driver, a fork of the upstream XCUITest driver. The fork keeps the public driver protocol identical to upstream but redirects three things that upstream assumes are local: - xcrun simctl calls, forwarded to the macOS host running your simulator. - File copies, uploaded through the Limrun API instead of writing to disk. - Safari debugging, proxied through a tunnel because upstream scans local UNIX sockets under /tmp/. WebDriverAgent itself is upstream. You install it on the instance as a normal app, and the driver talks to it over an authenticated HTTPS tunnel. Android uses any upstream Appium server (UiAutomator2, Espresso, Flutter, all of them) connected through an ADB tunnel opened by the Limrun SDK or CLI. Once adb devices shows the remote emulator on 127.0.0.1, Appium treats it exactly like a USB-attached device. Test code is portable. The same webdriverio or appium-python-client script that runs against a local simulator runs against a Limrun instance with only the capabilities changed. Run an iOS test iOS needs more setup than Android: install a forked driver, provision an instance with WebDriverAgent on it, build the URL Appium uses to reach WDA, then fill in the capabilities. If you only need Android, skip to Run an Android test. Install Appium and the driver Start the Appium server in another terminal: The fork registers itself as @limrun/xcuitest (both driverName and automationName), so it coexists with the upstream xcuitest driver. Pick it via appium:automationName in your capabilities. Provision an instance with WebDriverAgent WebDriverAgent isn't on a fresh instance by default. Hand the platform a download URL for the WDA build when you create the instance, and the platform will install it and auto-launch it before create returns: The Python SDK covers control-plane operations (create, list, get, delete). It doesn't ship a device-control client, so the WDA health check below stays in TypeScript or Go. The Appium session itself runs in whatever language your test code is in. reuseIfExists matches existing instances by labels and region. If you don't pin spec.region, the platform may schedule your next create in a different region (closer to your current IP, for example), and the label match misses, so a fresh instance gets created instead. For repeatable reuse across CI runs or local dev sessions, pin region: 'us-west' (or whichever region you prefer) explicitly. When the call resolves, three fields on the returned instance object feed into Appium over the next few sections: - instance.status.apiUrl. The control-plane URL the Limrun SDK talks to. Appium also uses it for simctl calls and file uploads. - instance.status.token. The bearer token. Every URL on this instance authenticates with it, including WDA. - instance.status.targetHttpPortUrlPrefix. An HTTPS prefix that proxies to any port inside the simulator. The next section uses it to build the URL Appium hits on WDA's port 8100. For production, push the WDA .zip to Asset Storage once and reference it by name, instead of pulling from GitHub on every run. Point Appium at WDA on the instance Appium drives iOS by sending HTTP requests to WebDriverAgent. When WDA runs on your laptop, Appium talks to http://localhost:8100. With Limrun, WDA is on a Mac in the cloud, so you need a public URL that reaches port 8100 inside the simulator. targetHttpPortUrlPrefix from the previous section is exactly that. To reach a specific port on the simulator, append the port number directly to the prefix, with no slash. WDA at port 8100 lives at 8100. There's one more quirk: Appium's URL parser needs an explicit port on the hostname, otherwise it builds invalid URLs when it makes follow-up requests. Insert :443 (the HTTPS port) into the hostname: You'll pass wdaUrl to the driver as the appium:webDriverAgentUrl capability, and set appium:wdaLocalPort: 443 so the two agree. WDA can crash during long runs, and the auto-launch you set up at create time only fires on first install. Before each session, ping WDA's /status endpoint with the bearer token. If it doesn't answer cleanly, relaunch WDA from the platform side. The Limrun SDK gives you an instance client that can run simctl on the host Mac for exactly this: iOS capabilities Pick your Appium client below. The capability keys are the same across all three; only the surrounding code differs. The Limrun-specific capabilities: | Capability | Required | What it does | |---|---|---| | appium:limInstanceApiUrl | yes | The control-plane URL (instance.status.apiUrl). The fork uses it for simctl, file uploads, and Safari socket tunneling. | | appium:limInstanceToken | yes | The bearer token (instance.status.token). Authenticates the URL above. | | appium:limLogLevel | no | One of none, error, warn, info, debug. Defaults to info. | | appium:wdaRequestHeaders | yes | Extra HTTP headers added to every WDA call. Use it to send the bearer token. | | appium:webDriverAgentUrl | yes | The WDA URL built above (the port gateway URL with :443 inserted, plus 8100). | | appium:wdaLocalPort | yes | Must be 443 to match the WDA URL. | | appium:usePreinstalledWDA | yes | Tells the driver to attach to the WDA you already launched, instead of building its own. | | appium:noReset | recommended | Stops Appium from wiping WDA between sessions. | Everything else is standard XCUITest: appium:bundleId for a native app, browserName: 'safari' for the browser context, locator strategies, contexts, all unchanged. A minimal Safari test Once the driver is connected, drive Safari the same way you would locally. The example below opens Hacker News, switches into the WebView context so CSS selectors work, and clicks the "More" link: For a native-app session, install the app first (either at instance create through initialAssets or after boot through installApp), drop browserName, and add appium:bundleId: The full runnable example is at typescript-sdk/examples/appium-ios. Run an Android test Android is simpler: open an ADB tunnel, then use any upstream Appium server. UiAutomator2, Espresso, and Flutter drivers all work unchanged. Open the ADB tunnel From Appium's side, the emulator is indistinguishable from a USB-attached device. Install Appium Android capabilities There are no Limrun-specific capabilities for Android. The tunnel makes the device local, so any capability documented for the upstream UiAutomator2 driver works as-is. To install an APK before the session, push it through the tunnel like a normal local device: Or pre-install it at instance creation with initialAssets. See Run an Android Emulator for the create flow. Run from a Linux CI runner Neither iOS nor Android automation needs a macOS host. The Mac lives on Limrun's side. A standard ubuntu-latest GitHub Actions job is enough. For Android, replace the Install Appium and driver step above with the upstream UiAutomator2 driver and make sure adb is on the runner's PATH (the GitHub-hosted ubuntu-latest image ships it under $ANDROID_HOME/platform-tools/): The CI runner doesn't need Xcode, the iOS SDK, or a local Android emulator. For iOS it needs Node, Appium, and LIMAPIKEY. For Android, add adb on the PATH (Appium's UiAutomator2 driver shells out to it, regardless of whether you open the tunnel through the CLI or startAdbTunnel). Add --label session=${{ github.run_id }} when creating instances and tear them down at job end (or on failure). Inactivity timeouts handle the rest if a job crashes. See Reuse and clean up instances. Troubleshooting WDA isn't responding. Hit ${wdaUrl}/status with Authorization: Bearer ${instance.status.token}; if it 502s, relaunch WDA via simctl launch booted com.facebook.WebDriverAgentRunner.xctrunner. Long-running suites should do this check before every session. You're running upstream xcuitest, not the fork. Confirm with appium driver list --installed. The Limrun driver appears as @limrun/xcuitest, and appium:automationName must match exactly. Safari debugging is proxied through the fork; without it, getContexts() only returns NATIVE_APP. Make sure appium:limInstanceApiUrl and appium:limInstanceToken are set so the driver can open the Safari socket tunnel. The tunnel was idle long enough for the WebSocket to close. The SDK auto-reconnects; if you used lim android connect and it exited, restart it. Bumping the instance's inactivityTimeout also helps for long suites. Next steps Provision, configure, and drive iOS instances directly. The control surface Appium sits on top of. ADB tunnel internals, instance configuration, and the Android control SDK. The other testing route: Android Chrome over CDP. Cache the WebDriverAgent bundle and your app builds so CI runs don't pull them from GitHub on every job. --- ## Playwright URL: /docs/testing/playwright Drive Chrome on a Limrun Android emulator with Playwright. The CDP WebSocket terminates on the emulator's host, so tests stay responsive across regions. Playwright Playwright on Limrun automates Chrome on Android emulator instances. Your existing Playwright test code keeps working. Before you start - A Limrun API key in LIMAPIKEY (get one from console.limrun.com). If you've never used the CLI before, walk through the Quickstart first. - Node 20 or newer. - playwright@1.56 in your project. The Android device API ships with the regular playwright package under an internal export (_android). The sub-sandbox server is pinned to 1.56, so newer or older client versions reject the WebSocket handshake with a "Playwright version mismatch" error. What's supported Playwright on Limrun is Android Chrome only. Each Android instance ships with Chrome, and Limrun exposes Chrome's DevTools Protocol (CDP) endpoint to your test as a single WebSocket URL. iOS Safari and desktop Chromium aren't routed through this integration; if you want Safari, look at Appium. There are two ways to reach that CDP endpoint, and the choice has real consequences for test speed. The Playwright Android sub-sandbox (recommended). Limrun runs a Playwright-aware service on the same host as the emulator and exposes its WebSocket to you. Your test attaches to that WebSocket, and the CDP-to-emulator hops stay on the emulator's host. As a concrete reference, on one cross-continent run (laptop in Bengaluru, instance in Helsinki), the runnable example below (page load, three clicks, screenshot) finished in about 4.5 seconds. A local ADB tunnel. You can open an ADB tunnel and let Playwright drive the device through adb. It works, but every CDP call goes from your laptop → ADB tunnel → emulator. CDP is a chatty protocol (each click or locator lookup is several request-response round-trips), so suites get noticeably slower the further your test runner is from the instance's region. The rest of this page uses the sub-sandbox; the tunnel path is covered at the end. Provision the instance Two things change relative to a plain Android create call. First, turn on the Playwright sub-sandbox so the instance exposes a CDP WebSocket. Second, ship Chrome a flag that lets it accept command-line arguments from Playwright (Chrome on Android ignores them by default). Both go into a single create call: The Python SDK covers control-plane calls like this one. Playwright's Android device API ships only in the Node binding, so the rest of the page stays in TypeScript. reuseIfExists matches existing instances by labels and region. If you don't pin spec.region, the platform may schedule your next create in a different region (closer to your current IP, for example), and the label match misses, so a fresh instance gets created instead. For repeatable reuse across CI runs or local dev sessions, pin region: 'us-west' (or whichever region you prefer) explicitly. When the call resolves, two things on the instance object matter for Playwright: - The CDP WebSocket URL. This is the endpoint Playwright connects to. On the returned object, it's instance.status.sandbox.playwrightAndroid.url. - The bearer token. Authenticates the URL above. It's instance.status.token. Connect Playwright playwright-python, playwright-java, and playwright-dotnet don't expose the Android device API. If your stack can't run Node, use the Appium Android flow instead. The API lives behind Playwright's internal _android export. Pass the CDP URL with the bearer token as a query parameter, then go through Playwright's regular Android flow: warm up Chrome, launch the browser, open a page. Run a test Once launchBrowser() returns, you're back on the standard Playwright API: The full runnable example is at typescript-sdk/examples/playwright. The local-ADB alternative If you'd rather not enable the sub-sandbox, you can open an ADB tunnel and use Playwright's normal android.devices() flow: This is the path the Appium page uses for UiAutomator2. It works for Playwright, but every CDP call traverses your laptop, so expect noticeably slower test runs. Troubleshooting instance.status.sandbox.playwrightAndroid.url is undefined. Either wait: true wasn't set on create (so the status hasn't populated yet), or spec.sandbox.playwrightAndroid.enabled wasn't passed. Recreate with both. The enable-command-line-on-non-rooted-devices Chrome flag is required for any Playwright Android session. Pass it as a Configuration asset at create time as shown above; setting it after boot doesn't work. A leftover Chrome process is holding the CDP socket. Stop Chrome explicitly before closing: await device.shell('am force-stop com.android.chrome'), then device.close(). Check that you're calling android.connect(url) with the URL from status.sandbox.playwrightAndroid.url, not android.devices() after opening an ADB tunnel. The two paths look similar in code but the second one routes every CDP call through your laptop's ADB. Next steps Cross-platform mobile automation: iOS Safari + native, Android native apps. Instance lifecycle, ADB tunnel internals, and the Android control SDK. Configuration assets like the Chrome flag used here, plus app assets for native testing. ---