# Limrun docs > Cloud infrastructure for mobile development: iOS simulators, Android emulators, and Xcode builds in the cloud, controlled through one CLI or SDK. ## Introduction URL: https://docs.limrun.com/docs Run iOS simulators, Android emulators, and Xcode and Gradle builds in the cloud, and drive them from any machine, CI runner, or coding agent. What is Limrun Any process that can reach an HTTPS endpoint can build a mobile app on Limrun, run it, and drive it: a coding agent on a Linux VM, a CI runner without macOS minutes, or a backend that hands live devices to its own users. What you can run Everything you create on Limrun is an instance. There are four kinds: | Instance | What it is | What you do with it | |---|---|---| | iOS simulator | The iOS Simulator on a cloud Mac, as an iPhone, iPad, Apple Watch, or iPhone Duo. | Stream it in a browser. Tap, type, read the accessibility tree, record video, install and launch apps, stream logs. | | Android emulator | An Android emulator with an ADB tunnel. | Drive it with the lim CLI or the SDKs, or attach Android Studio, Appium, and scrcpy over ADB as if it were a USB device. | | Xcode sandbox | A cloud Mac with Xcode. | Sync your source and run xcodebuild, or serve Bazel remote build execution. Sign IPAs and upload them to App Store Connect. | | Gradle sandbox | A cloud build environment for Android. | Sync your source and run your project's Gradle wrapper. Sign AABs for Google Play. | Build and run instances compose. Attach a simulator to an Xcode sandbox and every successful build installs and launches on it. Build artifacts go to Asset Storage, from where any new simulator or emulator can install them at boot, and reviewers can open them from a preview link in the browser. What teams use it for - Coding agents shipping mobile code. An agent on a Linux VM picks up a ticket, builds the app on a cloud Mac, drives the simulator to verify the change, and posts a preview link on the pull request. - Mobile CI on Linux runners. Run xcodebuild, Gradle, XCTest, Maestro, and Appium from an Ubuntu runner, and post build artifacts and preview links on every pull request. No macOS minutes. - Live devices inside a product. A backend creates instances on behalf of its users, and the frontend streams them with `. Choose your path Build a sample iOS app on a cloud Mac and watch it run in your browser. Install the lim CLI and the Limrun skills where your agent runs. Run xcodebuild, Gradle, and test suites from a Linux runner. Stream a live simulator or emulator into your web app with . Ways to drive Limrun Every interface calls the same REST API, so you can mix them. A backend can create an instance with the SDK while an agent drives it through the CLI. - The lim CLI is for coding agents, CI jobs, and people at a terminal. It covers every operation. See Set up a coding agent and the CLI reference. - The SDKs for TypeScript (@limrun/api), Python, and Go are for backends that provision instances on behalf of users. The TypeScript SDK covers the full surface and the CLI is built on it. See SDKs and REST API. - from @limrun/ui` streams a live device into a React app. See Embed a device. - MCP servers let MCP clients create and drive devices through tool calls: one remote server for your organization, and a per-instance server on every simulator and emulator. - The console at console.limrun.com manages API keys and instances and has a Playground that streams a device from a dropdown. To see how instances, tokens, and timeouts fit together, read Concepts. --- ## Claude Code on the web URL: https://docs.limrun.com/docs/agents/claude-code-web Give every Claude Code session on the web what it needs to build and run your iOS app on Limrun. Claude Code on the web Claude Code on the web runs each session on a Linux VM with no access to macOS, so it cannot run Xcode or the iOS Simulator on its own. Set up one cloud environment with the lim CLI, the Limrun skills, and your API key. From then on, Claude builds your iOS app on Limrun's Macs from any of your repositories and runs it on a simulator you open in your browser. Set up the environment Open claude.ai/code. In the environment menu below the prompt box, choose Cloud, then Add cloud environment. Create an API key for this environment first: open console.limrun.com/settings, go to API Keys, and click Create Token. Then fill in the dialog: - Name: limrun. - Network access: Full. To allow only specific domains instead, see Restrict network access. - Environment variables: your key. - Setup script: installs the lim CLI, then every Limrun skill into ~/.claude/skills, so Claude has the skills in any repository, including ones that do not commit them. It runs at the start of every session, before Claude Code launches. Anyone who can use the environment can read its variables. Use a key created only for this environment, and delete it in the console if the environment is ever shared more widely than you intended. Sharing the environment with your organization makes the key visible to every member who can use it. Click Add environment. Below the prompt box, pick the limrun environment and your repository, then ask Claude to build and run your app: How sessions behave - Claude picks the build path from the skills. The limrun-xcode skill covers xcodebuild projects and limrun-xcode-bazel covers Bazel workspaces. - Asking to see the app attaches a simulator. From then on, every successful build installs and relaunches the app on it, and Claude's reply links to the live simulator, which you can tap and type in from your browser. - Instances outlive the session. The Xcode sandbox and the simulator stay up until your organization's inactivity timeout deletes them. To stop them right away, ask Claude to clean up before you close the session. - Each new session sets up again. The setup script runs at the start of every session, and Claude starts the build setup over. Restrict network access Full network access is the simplest setup. To use Custom instead, keep the default allowlist enabled, since it covers package registries such as npm that the setup script needs, and add Limrun's domains: Then add the hosts your own build downloads dependencies from. If a step fails with a network error, add the host it tried to reach and start a new session. For how the levels work, see Anthropic's network access documentation. Next steps Build a template app, change it with Claude, and review it in a pull request preview. The loop the skills run, and how to set them up in other agents. Remote xcodebuild builds for projects without Bazel. Remote build execution for Bazel workspaces. --- ## Set up a coding agent URL: https://docs.limrun.com/docs/agents/cli Set up a coding agent to build, run, and share your mobile app on Limrun by itself. Set up a coding agent Install the lim CLI and the Limrun skills where your coding agent runs, then ask it for a product change. The skills teach the agent the whole loop: build on a remote Mac, run the app on a cloud simulator, check the screen, and hand you a link to review. Install the CLI The CLI needs Node.js 20 LTS or newer. Install it with your package manager: Set your API key Create an API key at console.limrun.com/settings under API Keys, then export it in the shell your agent uses: The CLI also reads LIMAPIKEY from a .env file in the working directory. The API key can create, list, and delete every instance in your organization. If the agent runs in a sandbox that should not hold that much access, skip this step and give it a single instance instead. See Sandboxed agents. Install the Limrun skills From your project root, install the skills: The command fetches the latest skills from the public limrun-inc/skills repository and installs them into each agent's native skills directory without prompting. By default it targets every agent that already has a skills directory, or all supported agents when none exists, and uses project scope. In project scope, the Bazel and Detox skills are included only when a scan of the folder finds matching clues; a global install includes every skill. Narrow or change the install with these flags: | Flag | What it does | |---|---| | --agents claude\|cursor\|codex | Install for one agent. Repeat to pick several. | | --skills | Install one skill, such as limrun-xcode. Repeat to pick several. | | --scope project\|global | Install into the project (default) or into the agent's global skills directory. | | --keep-existing | Leave existing skill directories that differ from the fetched version untouched instead of updating them. | Existing skill directories with different content are updated in place, so review the change in your version control diff. Ask for a change Open the same project in the agent, then ask for the change you want and say that it should build and run the app on Limrun: What the agent does The skills walk the agent through this loop. Each step links to the page that documents its commands in full. 1. Build the app. lim xcode build . creates or reuses an Xcode sandbox, syncs the source to it, runs xcodebuild remotely, and streams the logs back. See Build with Xcode. 2. Attach a simulator. lim ios create --attach --reuse-if-exists --label issue= boots a simulator attached to the sandbox. The attach installs and launches the latest build, and every later successful build reinstalls it. With --reuse-if-exists, a rerun with the same labels lands on the same instance. See Run a simulator. 3. Share the live view. The create output includes a signed stream URL, labeled Signed Stream URL. Anyone with it can watch and use the simulator in a browser without signing in, so the agent shares it at the start of the task. 4. Read the screen, then act. Before every tap, the agent reads lim ios element-tree and picks elements by accessibility ID instead of pixel coordinates. See Run a simulator. 5. Record video for changes that involve motion, and attach it to the pull request. See Record a video. 6. Share a preview link. A build with --upload lands in Asset Storage, and https://console.limrun.com/preview?asset=&platform=ios opens it in the console for members of your organization. See PR previews. 7. Delete the instances when the work is done with lim ios delete and lim xcode delete. Tips for agents - The element tree is big. Pipe lim ios element-tree --json through jq or grep so it does not fill the agent's context window. - Build errors are the agent's job. When lim xcode build fails, the agent reads the output, fixes the code, and rebuilds instead of handing the failure back to you. - Unknown bundle ID? Check the Xcode project, or run lim ios list-apps after a successful build. - Delete defaults to the last instance. Without an ID, lim ios delete removes the last iOS instance the CLI created in this workspace. If the agent created several, list them by label first with lim ios list --label-selector "issue=LIM-34". Next steps Set up a cloud environment so every web session can build and run your app. Let an MCP client create and drive devices with OAuth sign-in, no CLI required. Hand an agent one instance without giving it your API key. Every lim command, shared flag, and environment variable. --- ## Per-instance MCP server URL: https://docs.limrun.com/docs/agents/mcp Wire one instance's own MCP server into a coding agent for device-control loops that need no organization credential. Per-instance MCP server Every iOS and Android instance runs its own Model Context Protocol server. Connect an agent to it when the agent should drive one existing device through native tool calls instead of shelling out to the lim CLI. - One server per instance. The instance returns its MCP URL once it is ready. For one organization-level endpoint that also creates and deletes instances, use the remote MCP server instead. - Auth uses the instance token. Pass status.token as an Authorization: Bearer header, not your API key. - The tools cover device control. Screenshots, taps, app lifecycle, and logs. Builds and asset uploads stay on the CLI. Choose 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 | API key in the environment or config, or a single pinned instance with no key | The instance token as Authorization: Bearer | | Connection | A background WebSocket that repeated commands reuse (--daemon, on by default), or one started explicitly with lim session start | A long-lived MCP session | | Scope | Everything Limrun does. See the CLI reference. | Device control only: five tools on iOS and three on Android, listed below. No builds, no asset uploads. | | Best for | Builds, instance lifecycle, asset management | Tap, screenshot, and element-tree loops where round-trips matter | Most agents use both: the CLI for provisioning, builds, and assets, and MCP for native tool calls over device control. Get the MCP URL and token Read mcpUrl and token from the instance's status. Both come back on the same response, and you need both to talk to the server. The CLI example reads an instance you already have; the SDK examples create one, or reuse one with the same labels: CLI only: lim ios get reads the last iOS instance the CLI created; pass an instance ID (lim ios get --json) to read another one. Android instances return mcpUrl the same way (not exposed in Go SDK v0.9.0). The URL carries no credentials. Authenticate every MCP request with the instance token in an Authorization: Bearer header. The token is scoped to this one instance: it works only while the instance is running and cannot be used for anything outside it. Do not use your API key here; the API key is for creating, listing, and deleting instances. For a running instance, the console at console.limrun.com gives copy-paste snippets for each supported agent. Open the instance and click the connect icon in the sidebar: Connect your agent Claude Code Add the server with the two values from the create response: Verify it with claude mcp list. The console's connect modal renders the same command with your values filled in: Claude Desktop To use Limrun from Claude Desktop, add the remote MCP server as a custom connector in its settings. The remote server signs in with OAuth, so it needs no custom header. The per-instance server needs an Authorization header, so use it only from clients that let you set one. Cursor The console's Add to Cursor button opens Cursor with the server pre-filled. To add it by hand, create an MCP server in Cursor's settings with the URL and the Authorization: Bearer header. Other MCP clients Any MCP client that speaks the HTTP transport works. Pass the URL as the endpoint and Authorization: Bearer as a header. The connect modal's Custom section shows both values: Tools The server's tools depend on the platform. An iOS simulator's 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. Call it 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, including Safari URLs and custom schemes such as exp:// for Expo Go. | | ios-app | Lists installed apps and launches or terminates them. It does not install apps; use the CLI or SDKs for installs. | | ios-logs | Fetches the last N combined stdout and stderr log lines for a bundle ID. | An Android emulator's server exposes three: | Tool | What it does | |---|---| | android-screenshot-and-element-tree | Returns the current screenshot and the UIAutomator element tree in one call. | | android-use | Performs a batch of actions on the emulator in order. | | android-open-url | Opens a URL or deep link on the emulator. | The actions inside ios-use map onto methods of the TypeScript SDK's createInstanceClient. The server batches them into a few tools instead of one tool per action, so the agent can group related steps into one round-trip. touchDown, touchMove, and touchUp accept optional x2 and y2 coordinates for a second finger. Supply both on every touch action of a two-finger gesture, or omit both for one finger. Both fingers share the optional screenWidth and screenHeight coordinate space. A tapElement action whose selector matches nothing fails in about a second and aborts the batch. iOS creates list rows lazily, so a row below the fold is often absent from the tree. When you expect that, put scroll actions before the tapElement in the same batch. App installs, Xcode builds, and asset uploads are not on the server. MCP gives a tool no access to the client's filesystem, so anything that sends bytes from your machine (an APK, a .app bundle, a build artifact) lives on the CLI and SDKs. Next steps The full device-control surface these tools call into. Instance lifecycle, builds, and assets through the CLI. --- ## Remote MCP server URL: https://docs.limrun.com/docs/agents/remote-mcp Sign in once with OAuth or an API key, then let an MCP client provision and clean up devices by itself. Remote MCP server https://mcp.limrun.com/mcp is Limrun's organization-level Model Context Protocol server. Connect it once, and any MCP client can create iOS simulators and Android emulators, drive them by screenshot and accessibility tree, and delete them when done. - One endpoint, signed in with your Limrun account. OAuth is the default: the client opens a browser, you approve access, and tokens refresh on their own afterwards. For headless setups, an API key in an Authorization: Bearer header works too. - Stateless. Every tool that touches a device takes an explicit instanceId, so there is no session to lose. Reconnects and long gaps are fine. - Instances only. An OAuth connection can create, inspect, drive, and delete instances and list your app assets. It cannot reach billing or the rest of the API. Builds and file uploads stay on the lim CLI. Choose the remote or per-instance server Every instance also runs its own MCP server. Pick by who holds the credential: | Aspect | Remote server | Per-instance server | |---|---|---| | Endpoint | https://mcp.limrun.com/mcp for the whole organization | One URL per instance, from status.mcpUrl | | Auth | OAuth sign-in, or an API key | That instance's status.token | | Lifecycle tools | Create, list, inspect, and delete instances | None; the instance must already exist | | Blast radius | Every iOS simulator and Android emulator in the organization | One instance; the token stops working with it | | Best for | An agent that owns the whole loop, from "get me a device" to cleanup | Handing one device to an agent that holds no organization credential | Connect from Claude Code Add the server: Then open a Claude Code session, run /mcp, select limrun, and choose Authenticate. After you approve access in the browser, /mcp shows the server as connected with its 16 tools. Access tokens rotate automatically from then on, and you are not asked to sign in again unless you revoke access. Connect from other MCP clients Any client that speaks the MCP Streamable HTTP transport works with one of two auth setups: - OAuth. Point the client at https://mcp.limrun.com/mcp and complete the sign-in it triggers. The server advertises its authorization server through the standard discovery metadata, so clients that support remote MCP servers find the flow on their own. - API key. Configure the same URL with the header Authorization: Bearer . Use this where no browser is available, such as CI or a cloud agent. Create keys in the console under Settings, API Keys. The server is listed in the official MCP registry as com.limrun/mcp and on Smithery, where you can also connect it through the Smithery gateway. Tools The server exposes 16 tools in five groups. Lifecycle and assets. These take no instanceId; they operate on the organization. | Tool | What it does | |---|---| | create-ios-simulator | Creates a simulator, blocks until it is ready, and returns the instance ID. Optional apps pre-installs apps by asset name or public URL, optional inactivityTimeout overrides the organization default, and optional idempotencyKey makes retries return the same instance. | | create-android-emulator | Same contract for Android emulators. apps entries are asset names or public APK URLs. | | list-instances | Lists the organization's running iOS simulators and Android emulators with their IDs and states. Xcode and Gradle sandboxes are not listed. | | get-instance-status | Returns the state and error message for one instance ID. | | delete-instance | Terminates an instance. This is the only destructive tool. | | list-apps | Lists the app asset names you can pass to the apps field of the create tools, per platform. | Live view. stream-device takes an instanceId and shows the user the live, interactive screen of that instance, inline where the client supports it. iOS device control. ios-screenshot-and-element-tree, ios-use (batched taps, typing, scrolling, and the rest of the action surface), ios-open-url, ios-app (list, launch, terminate), and ios-logs. Android device control. android-screenshot-and-element-tree, android-use, and android-open-url. Each device-control tool, like stream-device, takes the instanceId a create tool returned. The action surfaces match the per-instance server, which documents them in detail. Skills. get-agent-skill is covered in Skills for builds and local files. Apps install at creation time through the apps field. There is no install tool for a running instance: MCP gives the server no access to the client's filesystem, so anything that moves local bytes (an APK, a simulator build, an Xcode project) belongs to the CLI and SDKs. Upload builds to Asset Storage first and they show up in list-apps. Instances created through this server carry a created-by: mcp label. Skills for builds and local files The server's initialize response tells clients that builds and local files belong to the lim CLI, and it serves the step-by-step CLI procedures as Agent Skills in three forms: - The get-agent-skill tool. Pass one of limrun-xcode, limrun-gradle, limrun-xcode-bazel, or limrun-expo-development. - skill:// resources with a skills/list catalog, for clients that follow the MCP skills extension. - The same content installed locally. Where the lim CLI is installed, use lim skills install; before it is, npx skills add limrun-inc/skills installs the same skills. An agent that needs to build something asks for the skill, installs the CLI it describes, and continues in its own shell. Revoke access Tokens minted through OAuth appear in the console under Settings, API Keys, described as MCP OAuth tokens. Revoking one invalidates only the current access token: the client is challenged on its next request and rotates to a fresh token with its refresh token, without asking you to sign in again. To disconnect a client, remove the server from that client's configuration. Next steps Hand a single instance to an agent using only that instance's token. Builds, uploads, and everything else the MCP tools leave out. Upload the app archives that list-apps offers to the create tools. --- ## Sandboxed agents URL: https://docs.limrun.com/docs/agents/sandboxed-agents Hand an agent in an untrusted sandbox one instance to drive, while instance lifecycle and your API key stay outside. Sandboxed agents LIMAPIKEY is an organization-wide credential: it can create, list, and delete every instance you have. An agent in an untrusted or shared sandbox should not hold it. Create the instance outside the sandbox and hand the agent only that instance's own URL and token. The lim CLI drives a pinned instance without any API key. Create the instance and capture its credentials Run the create where the API key lives: your machine, the orchestrator, or CI. Every instance returns its own status.apiUrl and status.token: Create each resource as the agent needs it: | Resource | CLI | TypeScript SDK | |---|---|---| | iOS simulator | lim ios create --json | lim.iosInstances.create({ wait: true }) | | Android emulator | lim android create --no-connect --json | lim.androidInstances.create({ wait: true }) | | Xcode sandbox | lim xcode create --json | lim.xcodeInstances.create({ wait: true }) | | Gradle sandbox | lim gradle create --json | lim.gradleInstances.create({ wait: true }) | --no-connect skips the local ADB tunnel, whose output would otherwise mix into the JSON. wait: true returns once the instance is ready. Point the sandboxed CLI at the instance Export the pair inside the sandbox. Every lim ios command picks them up directly, and nothing is persisted: To pin the instance once per workspace instead, run set-instance, which stores it in ~/.lim/last-instances.json: Both forms exist for every instance type: | Instance type | Environment variables | Pin command | |---|---|---| | iOS simulator | LIMIOSINSTANCEURL, LIMIOSINSTANCETOKEN | lim ios set-instance | | Android emulator | LIMANDROIDINSTANCEURL, LIMANDROIDINSTANCETOKEN | lim android set-instance | | Xcode sandbox | LIMXCODEINSTANCEURL, LIMXCODEINSTANCETOKEN | lim xcode set-instance | | Gradle sandbox | LIMGRADLEINSTANCEURL, LIMGRADLEINSTANCETOKEN | lim gradle set-instance | If the agent should run lim android connect, also pass the ADB WebSocket URL (status.adbWebSocketUrl) as LIMANDROIDINSTANCEADBURL or with the --adb-websocket-url flag of lim android set-instance. When more than one is present, an explicit --id wins over the environment pair, which wins over the pinned instance. Build without the API key lim xcode build targets an Xcode sandbox, which is its own resource with its own credentials. Create it where the key lives: Then, inside the sandbox, export its pair and build: lim gradle build follows the same pattern with lim gradle create --json and the LIMGRADLEINSTANCEURL and LIMGRADLEINSTANCETOKEN pair. Share files through signed asset URLs Asset Storage lives on the management API, so the sandbox cannot mint asset URLs itself. Create the asset outside and hand the sandbox its signed URLs, which carry their own authorization. To let the agent ship a build artifact, create the asset where the key lives and pass its upload URL in. The SDKs return both URLs in one call without uploading anything: In the sandbox, the build uploads straight to that URL without touching the management API: To hand the agent a file, such as a prebuilt app or a fixture, push it outside with lim asset push ./MyApp.app.zip and read its download URL with lim asset list --name MyApp.app.zip --download-url --json. The agent installs it without a key, and the instance downloads the URL server-side: What still needs the key Anything on the management API fails with an authentication error inside the sandbox: creating, listing, and deleting instances, and minting asset URLs the way lim xcode build --upload does. That is the point. The agent drives the one instance it was given, and lifecycle stays with whoever holds the key. Delete the instance from the orchestrator when the work is done. Next steps Give the same instance token to an agent that prefers MCP tool calls. How API keys, instance tokens, and signed stream URLs relate. Shared flags and every environment variable the CLI reads. --- ## Build with Gradle URL: https://docs.limrun.com/docs/android/build-with-gradle Compile your Android app on a cloud Gradle sandbox from any machine, with no local Android SDK, JDK, or keystore. Build with Gradle The CLI syncs your source to a Gradle sandbox, runs the project's own Gradle wrapper remotely, and streams the log back. Install the APK on a cloud emulator, or sign an AAB and publish it to Google Play. The CLI covers the whole surface. The TypeScript SDK provisions and builds too, but its builds pass signing material explicitly because the escrowed release key (--sign) is CLI-only: | Surface | CLI | TypeScript | Python | Go | |---|---|---|---|---| | Provision a Gradle sandbox | ✓ | ✓ | via REST | via REST | | Sync source and run Gradle | ✓ | ✓ | not in SDK | not in SDK | | Escrowed release signing (--sign) | ✓ | pass the material yourself | not in SDK | not in SDK | The SDK capability matrix covers every other surface. Build a project One command does everything. It creates a Gradle sandbox (or reuses the one remembered for your git worktree), syncs the current directory, and runs assembleDebug with the project's Gradle wrapper: Pick other tasks with --task; repeat it for several: Bare React Native repositories usually need nothing extra: the sandbox finds android/ on its own. If the Gradle root is nested and auto-discovery is ambiguous, point at it with --project-path: The sync skips paths your .gitignore files exclude. Repeat --ignore '' to skip more paths, and --include '' to force-sync paths that a built-in rule or .gitignore would exclude. --env KEY=VALUE passes an environment variable to the build, and --basis-cache-dir sets where the client keeps its sync cache. To create the sandbox yourself, for example with labels or a jurisdiction, create it first; the build then uses it: Without --inactivity-timeout, a Gradle sandbox is deleted after 10 minutes of inactivity. In TypeScript, each step is explicit: create the instance, open a client, sync, then build. For long builds, return as soon as the build is accepted with --detach, read the log later with lim gradle logs, or receive the result on a webhook. Build logs and webhooks covers all three, including the webhook payload's applicationId, versionName, and versionCode. React Native and Expo projects Expo managed-workflow projects have no android/ directory. The sandbox detects them, installs dependencies, and runs expo prebuild before Gradle. Two flags tune that pipeline. Setting either one forces the Expo pipeline on, and the build fails when no Expo app is detected: - --expo-app-dir : the app directory inside a monorepo, relative to the synced root. - --abi : which Android ABIs to build (armeabi-v7a, arm64-v8a, x86, x8664, or all, which keeps the project's own configuration). Repeatable. Without it, Expo-pipeline builds target x8664, the ABI Limrun Android instances run natively (arm64-v8a also works through translation; see Supported ABIs). Release and bundle tasks keep the project's own ABI configuration. Select tools The sandbox image includes these mise-managed tools: | Tool | Included compatibility lines | Default | |---|---|---| | Node.js (with npm and npx) | 22, 24 | 22 | | pnpm | 9, 10, 11 | 10 | | Yarn | 1, 4 | 1 | | Bun | 1 (stable) | 1 | | Java | Temurin 17 | Temurin 17 | | bundletool | 1 | 1 | Builds use your project's Gradle wrapper. Android SDK, NDK, and CMake packages stay managed by sdkmanager. Select tool versions, install missing ones, and inspect the result: What each command does: - lim gradle use selects tools in an existing sandbox after the project has synced. Mise installs a requested version if needed. --cwd apps/mobile targets a nested project and --id chooses the sandbox. - lim gradle tools install syncs the project and runs mise install in the sandbox. --no-sync installs the current selections without syncing, --cwd apps/mobile targets a nested project, and --id chooses an existing sandbox. It never creates or replaces an instance. - lim gradle tools inspects an existing sandbox without syncing or creating an instance. --sync uploads local changes first, and --cwd apps/mobile inspects a nested project. Both forms need an existing sandbox; --id chooses one. Limrun imports [tools] declarations from synced project mise files. Those declarations override package-manager detection and image defaults. Most numeric project requests keep their major version; Ruby, Python, Go, Flutter, Dart, and pre-1.0 tools keep major.minor. Limrun can update patch and minor releases within those lines on its own, and latest opts out of a fixed line. Each build or run resolves its mise environment once. The first sandbox operation that reads a project mise file runs mise install before exporting that environment; this happens once per sandbox. Without a project mise file, commands use the image defaults. Later changes need an explicit lim gradle tools install, and so does a failed or cancelled first install, which also stops that operation. To pin an exact release, run lim gradle run -- mise use --pin node@24.5.0. Later operations respect that override, and lim gradle use node@24 replaces it with a Node 24 selection. The selected Node is exposed as NODEBINARY, and mise sets JAVAHOME. Sandbox paths such as HOME, PATH, and the Android SDK locations stay managed. Run commands lim gradle run syncs the current directory, then runs a one-shot shell command after -- in the remote workspace with the selected tools. Pass environment variables with --env, which lim gradle build accepts too: In TypeScript, sync the project first, then call run to install tools or run a command. run accepts cwd, env, and timeoutSeconds and returns the same streamed process interface as gradlebuild. CLI options: - --no-sync uses the existing remote workspace instead of syncing first. - The optional positional directory (lim gradle run apps/api -- make generate) is relative to the synced workspace. - --timeout accepts 1 through 21600 seconds and defaults to 3600. - --additional-file localPath=remotePath adds a file from outside the source tree, such as ~/.netrc=.netrc; the remote path is relative to the workspace. Repeat it for several files. Commands stream their output. Commands and builds share one slot per sandbox: starting a new command or build cancels the one that is running. Keep builds warm Reuse the same sandbox for later builds. It keeps its dependency caches and temporary files between builds and commands, until the instance is deleted. The image also ships warmed Gradle caches and a pnpm 10 store, so only another pnpm major needs an initial registry download. Tools you install yourself last for the sandbox's lifetime. A new sandbox starts from the image again: it does not inherit the previous sandbox's workspace or your installed tools. Dependencies reinstall only when the installed tool versions change. A selected tool that failed to install doesn't trigger reinstalls or block unrelated commands after the first attempt. Upload the build artifact There are two ways to get the artifact out of the sandbox. Upload to Asset Storage. The artifact lives in Limrun's managed storage, and later creates can install it by name. See Asset Storage. The uploaded asset expires 14 days after the last upload by default. Each build upload pushes the expiry out again, so actively rebuilt assets stay alive. Pass --upload-ttl with a Go duration (ttl in the SDK) to change the window: 720h works, 1d is not valid Go syntax. Assets uploaded with lim asset push are unaffected; they never expire unless given a --ttl. The build result includes a signed download URL. Anyone with the URL can download the artifact without a Limrun API key. The signature expires after 15 minutes; fetch a fresh one with lim asset list --name --download-url. Upload to your own bucket. Pass a presigned S3, GCS, or R2 URL and the sandbox PUTs the artifact straight to it, without a round trip through the machine that started the build: CLI only: --signed-upload-url defaults to the LIMSIGNEDUPLOAD_URL environment variable. To build on every push from a Linux runner, see GitHub Actions recipes. Troubleshooting | Symptom or message | Cause | Fix | |---|---|---| | The build can't find the Gradle root | Auto-discovery found more than one candidate, or the root is nested. | Pass --project-path , for example --project-path android. | | --expo-app-dir or --abi fails with no Expo app detected | Either flag forces the Expo pipeline on. | Remove the flag for native projects, or point --expo-app-dir at the Expo app. | | A new tool selection has no effect | The first mise install already ran for this sandbox. | Run lim gradle tools install. | | A running build stops with status CANCELLED | Another build or lim gradle run started on the same sandbox. | Run one operation at a time per sandbox, or use separate sandboxes. | | --upload-ttl 1d is rejected | 1d is not a Go duration. | Use hours, for example 24h. | | The download URL returns an error after a while | Signed download URLs expire after 15 minutes. | Run lim asset list --name --download-url for a fresh one. | Next steps Sign a release AAB with your organization's upload key and publish it to Google Play. Install the APK the build uploaded and drive the device. Detach from long builds, read logs later, and receive the result on a webhook. Build and sign on a plain Linux runner. --- ## Connect to local services URL: https://docs.limrun.com/docs/android/local-services Route an Android app's connections to a mock backend, dev server, or VPN-only API on your machine. Connect an Android emulator to local services lim android tunnel sends the connections you declare from the emulator through the machine that runs the tunnel, so the app keeps using the addresses it already has. This page covers Android emulators; the selector and inspection rules are shared with iOS and described once in Connect an iOS simulator to local services. Start a tunnel Start the tunnel before you launch the app. Connections the app opened earlier keep their original route until they close. Declare each destination as a selector, then query or stop the tunnel when you are done: CLI only: without --detach, the tunnel runs until you stop the command. Add --detach to keep it running in the background, and --verbose to log every forwarded connection and dial failure; with --detach those lines go to the tunnel log file that tunnel status points to. TypeScript only: start the tunnel on a device client that was opened with the instance's adbUrl (status.adbWebSocketUrl). The caller owns the returned tunnel; disconnecting the device client does not close it. Choose selectors Selectors take the same forms as on iOS: localhost:port, IPv4:port, [IPv6]:port, an exact domain, or a *. wildcard domain. The domain rules, the caps of ten exact selectors and 64 domain selectors, and the port 53 exclusion are described in Choose selectors and Limits and behavior. Two rules differ on Android: - Exact selectors need a port of 1024 or higher. A lower port fails with invalid tunnel route port . - Exact selectors are also reachable as 10.0.2.2:, following the emulator convention for the host machine. An app configured for 10.0.2.2:8080 reaches localhost:8080 on your machine. Apps that resolve DNS themselves over HTTPS (DoH) bypass domain interception; declare the resolved IP instead. Inspect tunnel traffic HTTP and HTTPS through the tunnel are inspected by default, with the same --[no-]inspect, --har, --har-body-limit, --persist, and --ttl flags as on iOS. See Inspect tunnel traffic for what each flag does and how inspection affects apps that pin certificates. This keeps a persisted network log for seven days: Use adb reverse instead For exact ports, adb reverse over the ADB tunnel also works. It forwards a port on the emulator to a port on your machine, without domain interception or traffic inspection: Troubleshooting | Symptom or message | Cause | Fix | |---|---|---| | invalid tunnel route port | The exact selector uses a port below 1024, or port 53. | Run the local service on a port of 1024 or higher and declare that port. | | The app ignores the tunnel | The app opened its connections before the tunnel started. | Start the tunnel first, then relaunch the app. | | A domain selector has no effect | The app resolves DNS over HTTPS, or the wildcard does not cover the bare domain. | Declare the resolved IP as host:port, or add the exact domain next to the wildcard. | | An app with certificate pinning fails through the tunnel | Inspection decodes HTTPS with a certificate the pinning app rejects. | Leave that host out of the selectors, or pass --no-inspect. | | adbUrl is required to manage a destination tunnel | The TypeScript device client was created without adbUrl. | Pass adbUrl: instance.status.adbWebSocketUrl to createInstanceClient. | Next steps Drive the app once it reaches your service: taps, typing, logs, recordings. The shared selector rules, inspection flags, and the Expo and Metro setup. --- ## Run an emulator URL: https://docs.limrun.com/docs/android/run-emulator Create a cloud Android emulator, install your app on it, and drive it from a script, an agent, or your Android tools. Run an Android emulator There are two ways in. The lim CLI and the TypeScript SDK give you typed device control: selectors, screenshots, recordings. An ADB tunnel makes the emulator look like a USB device, so Android Studio, Appium, scrcpy, and your own scripts attach to it unchanged. Before you start You need the lim CLI or an SDK and an API key in LIMAPIKEY; the Quickstart sets up the CLI and SDKs and REST API lists the SDK packages. To use the ADB tunnel, install adb. It ships with the Android SDK Platform-Tools, which the Android Studio command-line tools install. The CLI's shell, log, and file commands work without it. The three SDKs cover different parts of the Android surface. TypeScript has the full device-control client. Python and Go create and manage instances, and drive the device over the ADB tunnel. The SDK capability matrix lists every capability per language. Create an emulator Create an instance and set its labels and Android OS version: The CLI output includes the instance ID and a console URL that opens the live device stream in your browser. In the SDKs, wait: true returns once the instance is ready, and reuseIfExists: true (CLI: --reuse-if-exists) returns an existing instance with the same labels instead of creating a new one (see Labels and reuse). To watch the emulator, open the instance's signed stream URL (status.signedStreamUrl) in any browser; it carries its own token, so no sign-in or API key is needed. To show the device inside your own web app, see Embed a device. Every field the create call returns is described in Instance spec and status. Create options Flags for lim android create: | Flag | What it does | |---|---| | --jurisdiction us\|eu\|as | Keep the instance inside one jurisdiction. Hard constraint: creation fails when no region there has capacity. | | --region | Deprecated. A region preference such as us-east1: tried first, overflows to other regions when full. Use --jurisdiction. | | --os-version 14\|15 | Android OS major version. Defaults to 15. | | --model phone\|tablet | Android device model. Defaults to phone. | | --display-name | Human-readable name shown in listings and the console. | | --label key=value | Attach a metadata label. Repeat for multiple labels. | | --reuse-if-exists | Return the existing instance with exactly the same labels instead of creating a new one. Looked up in the region that handles the create. | | --inactivity-timeout | Inactivity timeout (1m, 10m, 3h). Without the flag, your organization's Android default applies. The maximum is 24h. | | --hard-timeout | Forced termination after this wall-clock window. Defaults to no cap. | | --install | Upload a local APK and install it while provisioning. Repeatable. See Pre-install APKs. | | --asset-ttl | Expiry for files uploaded by --install, as a Go duration (24h, minimum 1m). Does not affect --install-asset. Defaults to no expiry. | | --install-asset | Install an APK that is already in Asset Storage. Repeatable. | | --install-url | Install an APK from a signed download URL. Repeatable. | | --rm | Keep the command in the foreground with the tunnel and delete the instance when you stop it with Ctrl+C. Has no effect with --no-connect, which returns right away. | | --connect / --no-connect | Open the ADB tunnel after the instance is ready. Defaults to on. | | --daemon / --daemon=false | Run the ADB tunnel in the background, or keep it in the foreground. On by default. With --rm, the tunnel stays in the foreground so the instance can be deleted when the CLI exits. | | --adb-path | Use a non-default adb binary. Defaults to adb on PATH. | | --open / --no-open | Open the signed stream URL in your browser once the instance is ready. On by default. | | --record, --events, --app-logs | Start a persisted session recording, event log (taps, scrolls, commands), or app log capture as soon as the instance is ready. List them with lim android recordings, lim android events, and lim android app-logs. | | --persist-ttl | How long those captures are kept. Defaults to 72h. | Split APK groups, Chrome flags, the ClientIP and ClientLocation clues, and the Playwright sandbox (spec.sandbox.playwrightAndroid) are SDK-only. Every SDK field is described in Android spec. spec.model, spec.jurisdiction, and the ClientLocation clue are available in the TypeScript SDK and REST, not in the Python and Go SDKs. An organization inactivity default of 0 means instances are never terminated for inactivity. Choose a connection: SDK client or ADB tunnel There are two ways to drive the device: - The device client (the lim android commands and the TypeScript SDK's createInstanceClient) opens a typed WebSocket session to the instance. It covers the common moves: tap by selector, screenshot, element tree, record. - The 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 device client for typed results and built-in selectors; it also runs shell commands, streams logcat, and moves files without a local adb. Use the ADB tunnel for tools that need a real adb device (Android Studio, debugger attach, scrcpy, Appium), or when you call from Go or Python. | Capability | Device client (TypeScript 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 a typed AndroidSelector | Dump the tree, then parse the XML yourself | | Tap by selector | client.tap({ selector }) (server-side lookup and tap) | Dump the 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) or scrollElement(target, ...) | adb shell input swipe X1 Y1 X2 Y2 | | Open URL or deep link | client.openUrl('myapp://...') | adb shell am start -a android.intent.action.VIEW -d | | Record video | client.startRecording / stopRecording with localPath or presignedUrl sinks | adb shell screenrecord /sdcard/out.mp4 (3-minute cap per file), then adb pull | | Install APK from an HTTPS URL | client.sendAsset(url) (the instance downloads it) | Download locally, then adb install ./out.apk | | Install APK from a local file | assets.getOrUpload({ path }), then sendAsset(asset.signedDownloadUrl) | adb install ./build.apk | | List, launch, or stop apps | client.listApps(), client.launchApp(pkg, { mode, onExit }), client.terminateApp(pkg) | adb shell pm list packages / adb shell am start -n pkg/.Activity / adb shell am force-stop pkg | | Logcat | client.logcat(args) streams logcat output | adb logcat | | File push and pull | client.pushFile(path, destination?) / client.pullFile(path, localPath) | adb push local /sdcard/... / adb pull /sdcard/... | | Run an arbitrary shell command | client.adbShell(command, args) returns stdout, stderr, and exit code | adb shell | | Mirror the device UI | Open signedStreamUrl in a browser or embed ` | scrcpy -s 127.0.0.1: | | Attach Android Studio | Not applicable | Appears in the device selector while the tunnel runs; no pairing | | Drive with Appium | Not applicable | Point appium:udid at 127.0.0.1: | | Connection lifecycle | Typed getConnectionState and onConnectionStateChange with auto-reconnect | The adb server's built-in retry; adb reconnect if it loses the socket | | Languages | TypeScript (device control and tunnel); Go: tunnel only; Python: none, use lim android connect | Any language that can run adb | Open a device client from the TypeScript SDK createInstanceClient opens a WebSocket to the instance. Every method on the returned client is one round trip over that socket. It needs two fields from the create response: status.apiUrl, the HTTP base the WebSocket URL is derived from, and status.token, the instance token. status.token is returned only to credentials that can control the instance; a read-only API key or a viewer seat gets none. | Option | What it controls | |---|---| | apiUrl | HTTP base for the instance. The WebSocket endpoint is apiUrl + /ws, and recording downloads use the same base. Required. | | token | Instance token. The client sends it as a token query parameter on the WebSocket URL, and as Authorization: Bearer on the HTTP endpoints for files and recording downloads. Required. | | adbUrl | The instance's status.adbWebSocketUrl. Needed only to open an ADB tunnel with startAdbTunnel. | | adbPath | Path to the local adb executable used by tunnel workflows. Defaults to adb. | | logLevel | 'none' \| 'error' \| 'warn' \| 'info' \| 'debug'. Log lines are prefixed [Endpoint]. Defaults to info. | | maxReconnectAttempts | 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 it, up to maxReconnectDelay. Defaults to 1000 ms. | | maxReconnectDelay | Upper bound on the backoff. Defaults to 30000 ms. | The Go and Python SDKs have no Android device client. From those languages, drive the device through the ADB tunnel or the lim android CLI. Open 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 command keeps running until you stop it with Ctrl+C, and the emulator shows up in adb devices like any networked device. lim android create opens the same tunnel unless you pass --no-connect. In another terminal, check the connection and run anything adb understands: Tools that work over the tunnel: - Android Studio. No pairing is needed: the tunnel already runs adb connect, so while lim android connect (or create) is running, the emulator appears in Android Studio's device selector like any adb-connected device. Run, debug, and Logcat flow through it. - scrcpy. Run scrcpy -s 127.0.0.1: to mirror and control the device. - Appium. Set appium:udid (or appium:deviceName) to 127.0.0.1: with any upstream Appium driver. See Appium. To open the tunnel from code, use client.startAdbTunnel() in TypeScript or tunnel.NewADB in Go; Appium shows both. Reach services on your machine To let the app on the emulator reach a mock backend, dev server, or VPN-only API on your side, start a destination tunnel with lim android tunnel. See Connect to local services. Run shell commands and move files without a tunnel For one-shot shell commands and file transfer, the CLI talks to the instance directly, with no local adb install and no tunnel to keep alive. Everything runs with the permissions of the ADB shell user, so protected locations are rejected just as they would be for adb. adb-shell runs a command and returns its stdout, stderr, and exit code; the CLI exits with the command's exit code, and --timeout (milliseconds, default 30000) bounds it. Pass the command after --. Each argument is sent as a separate, quoted token, so shell features such as pipes need an explicit shell: pull-file and push-file behave like adb pull and adb push: Install apps Install your APK at boot, push rebuilds as patches, or install on a running instance. Pre-install APKs Set initialAssets at create time to install one or more APKs while the instance boots. Each APK must come from a publicly reachable HTTPS URL (a GitHub release asset, an S3 presigned URL) or from Asset Storage. After a successful installation the app launches automatically, so the instance is ready to use on first connect. For local files, the CLI's --install and the TypeScript and Go SDKs' assets.getOrUpload({ path }) upload to Asset Storage first and hand the instance a signed URL. The Python SDK has no such helper: call assets.getorcreate, PUT the file to the returned upload URL, then create the instance. See Upload an asset. Pre-install from a local file, an Asset Storage name, an asset ID, or a URL: The CLI installs each --install or --install-asset as its own app. In the SDKs, each initialAssets entry picks one source: an asset name, an asset ID, or a URL. The Android initialAssets entry has no launchMode field; the full entry shape is in Instance spec and status. Anything you upload once and reuse goes through Asset Storage. To push a build to an instance that is already running, see Install an APK. Supported ABIs Instances are x8664 emulators that also run arm64-v8a native code through translation, so an APK built for x8664, arm64-v8a, or both installs and runs. x86_64 runs natively and performs best; arm64-v8a lets you reuse a device build without adding an ABI to your pipeline. Keep the ABIs consistent within one APK. If some native libraries ship only for arm64-v8a while others also ship x8664, Android installs the app as x8664 and the arm64-only libraries fail to load with UnsatisfiedLinkError. APKs that contain only 32-bit armeabi-v7a code are rejected at install with INSTALLFAILEDNOMATCHINGABIS. Apps that execute their own bundled ARM command-line binaries, rather than loading native libraries, are not supported under translation. Split APKs If you ship your app as split APKs, a base.apk plus config.* APKs (config.xxhdpi.apk for screen density, config.en.apk for locale, config.arm64_v8a.apk for architecture), the whole set must install as one atomic group or the app won't run. Put the set in one entry with a plural source (AssetNames, URLs, or AssetIDs) and an array. The first file is the base APK; the rest are config splits, and the instance installs them with pm install-multiple. Split APKs are SDK-only. Each CLI --install or --install-asset becomes its own initialAssets entry, so the set would install as separate apps instead of a group. Use separate initialAssets entries to install unrelated apps in sequence; everything inside one entry is a single grouped install. Set Chrome flags To turn on an experimental Chrome feature, add an entry with kind: 'Configuration' to initialAssets. The only configuration today is ChromeFlag; the flag below lets Chrome accept command-line arguments on a non-rooted device, which Playwright needs. Chrome flags are SDK-only; the CLI has no flag for them. A Configuration entry carries an instance setting, not a file: it has no MD5, no signed URLs, and never appears in assets.list. Sync APK rebuilds lim android sync updates the APK on a running emulator with differential patching: it transfers an xdelta3 patch against the APK already on the instance, then installs the updated APK. You don't upload the full APK after every rebuild. First, upload a base APK to Asset Storage. The asset name defaults to the filename: Pre-install that base APK when you create each emulator: After rebuilding the APK locally or in a cloud development environment, sync it to the last-created Android instance: Pass --id to target a specific instance, and --watch to keep watching the APK file and sync each new build. --no-install pushes the patch without installing, and --launch-mode ForegroundIfRunning|RelaunchIfRunning sets what happens after the install. Without --id, the command uses the most recent Android instance and creates one if needed. Install on a running instance Install a local APK or one from a URL. Local files are uploaded to Asset Storage first, and the instance downloads URLs itself, so a URL must be publicly reachable (a GitHub release asset, an S3 presigned URL) or come from Asset Storage: CLI only: install-app uploads local paths MD5-deduplicated, and --asset-ttl sets their expiry. In TypeScript, sendAsset(url) tells the instance to download an APK and install it; for a local file, upload it with assets.getOrUpload({ path }) and pass the returned signedDownloadUrl. sendAsset is TypeScript-only. The Go SDK uploads with Assets.GetOrUpload; from Go and Python, install with lim android install-app or adb install over the tunnel. Python has no upload helper: call assets.getorcreate and PUT the file to the upload URL first. Read the screen Inspect the device with screenshots and the element tree. The TypeScript examples assume a client from createInstanceClient. Take a screenshot screenshot captures the current frame as a PNG and saves it to screen.png: The CLI writes the image to the path you give it. In TypeScript, screenshot() returns a base64 data URI that you decode yourself. Get the element tree Read the UI hierarchy as raw UIAutomator XML or as parsed nodes: A node in the raw tree.xml looks like this: The same node in tree.nodes carries parsed bounds with a center point you can tap: Record the screen Recording runs on the instance and writes H.264 in an MP4 container. Start a recording, drive the UI, then stop it: quality accepts the integers 5 through 10; the default is 5, and higher values increase bitrate and file size. In TypeScript, pick the sink that matches where the file should live: - Local file. stopRecording({ localPath }). The SDK streams the file to localPath before resolving. - Your bucket. stopRecording({ presignedUrl }). The instance 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}. Control the device Input actions take an AndroidElementTarget, which carries either a selector or explicit coordinates: Prefer selectors. Layouts shift between OS versions, and coordinates don't survive that. Find elements findElement queries the tree so you don't have to parse it. Every non-empty selector field must match: The same selector fields work for find-element, tap-element, type, and scroll: | SDK field | CLI flag | Matches | |---|---|---| | resourceId | --resource-id | The android:id/... resource string. The 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). | | packageName | --package-name | Owning app package. Scopes queries when the launcher is in front. | | index | --index | Sibling index. | | clickable | --clickable / --no-clickable | Clickable nodes only. | | enabled | --enabled / --no-enabled | Skip disabled controls. | | focused | --focused / --no-focused | The currently focused control. | | boundsContains | --bounds-contains-x --bounds-contains-y | The deepest node whose bounds contain that pixel. | Tap, type, and press keys Tap by selector first and fall back to coordinates. type writes into the focused field unless you target an input by selector or coordinates. press-key sends hardware and IME keys, with --modifier repeatable: pressKey accepts plain names (BACK, ENTER, A, TAB), digit strings ('4'), or full KEYCODE_* constants. The response echoes the resolved keycode so you can confirm what fired. Scroll scrollScreen swipes from the center of the screen. scrollElement scrolls inside a specific scrollable node such as a list, ViewPager, or ScrollView; on the CLI, pass selector flags or coordinates to lim android scroll. Both take a direction (up, down, left, right) and an amount in Android scroll units. The TypeScript call resolves with the gesture's start and end points, so you can check what was sent: The SDK defaults amount to 6 (a small flick) and the CLI to 300 (a long scroll). The value on the wire is the same, so pass the same number to both for identical behavior. Open URLs openUrl resolves the intent the way am start -a android.intent.action.VIEW -d would. It works for web URLs and registered deep-link schemes: If no installed activity matches the intent, the call fails with startActivity returned code -91 (Android's ActivityManager.STARTINTENTNOT_RESOLVED). That covers unregistered custom schemes (myapp://...) and standard schemes whose handler isn't on the base image: on the stock image mailto: fails because there is no mail app, while tel: and https:// succeed because the dialer and Chrome are present. Install the missing handler with Pre-install APKs at boot, or install it on the running instance. Launch and stop apps Launch an installed app by package name, then stop it. Mode ForegroundIfRunning (the default) brings a running app to the front; RelaunchIfRunning restarts it: CLI only: launch-app watches the app until it exits. When the app crashes, stops responding (ANR), or stops, the CLI prints the exit reason, crash details, and a recent log tail; --detach returns right after the launch. TypeScript only: onExit receives the app's recent logcat lines and exit details, including the crash stack trace when the app crashed. watchApp attaches the same callback to an app you didn't launch through the client, for example one opened by a deep link. listApps returns the package name as bundleId so the shape matches iOS. Read logs lim android app-log reads logcat output for one installed app: the last 100 lines by default, --tail for another count, or --follow to keep streaming: logcat runs logcat on the instance with no local adb. Arguments go to logcat verbatim: -d or -t 50 dumps and exits, -T 50 follows, and no arguments dumps the buffer and follows: The CLI exits with logcat's exit code. In TypeScript, logcat returns a process that emits line (and batched lines) events and an exit event with the exit code, or -1 when stopped. The SDK equivalents of adb-shell, push-file, and pull-file are adbShell(command, args, { timeoutMs }), pushFile(path, destination?), and pullFile(path, localPath). adbShell quotes each argument, so run pipes through an explicit shell: client.adbShell('sh', ['-c', 'dumpsys battery | grep level']). A non-zero exit code is returned in the result, not thrown. Simulate the camera, microphone, and network Play a local video file as the camera feed. Apps see it through their regular Camera2 or CameraX pipeline, and the file's audio track plays through the microphone in sync. The video loops unless you pass --no-loop, which plays it once and freezes on the last frame. Camera injection needs Android 15, the default --os-version; the SDK throws on Android 14 and older: Play a local WAV or MP3 file as microphone input. The CLI pushes the file with adb, so it needs adb locally (--adb-path to override); --once plays it once instead of looping: Limit Wi-Fi bandwidth in Kbps. Omit a direction to leave it unchanged, and pass 0 to clear that direction's limit: The TypeScript SDK methods are setCameraVideo(path, { loop }), clearCameraVideo(), playOnMicrophone(path, { once }) for a file already on the instance (upload it with pushFile), and setWifiBandwidth({ downKbps, upKbps }). Trust a CA certificate Add a CA certificate to the emulator's trust stores, for example the root of a private CA that signs your test servers. The file holds one PEM-encoded CA certificate; anything else is rejected: The CLI prints the certificate's file name in the system store and its SHA-256 fingerprint. Apps, WebViews, and the Chrome browser trust the CA on their next connection, without a restart, and it stays trusted for the life of the instance. Apps that pin their own certificates still reject it. To see the HTTP and HTTPS traffic an app sends to the destinations you tunnel, use tunnel inspection instead. It decodes HTTPS with a CA the emulator already trusts, so you need no CA of your own. In the TypeScript SDK, addCaCertificate(pem) takes the PEM text and resolves to { filename, sha256 }. It needs a device client opened with adbUrl. Manage the connection The TypeScript client exposes lifecycle hooks for long-running drivers and connection debugging. It pings the WebSocket every 30 seconds and reports state changes through a callback. Call keepAlive for an explicit application-level ping (during a long idle stretch, or before a known network transition), and use getConnectionState and onConnectionStateChange to observe the connection: The client reconnects on transient failures with exponential backoff (defaults: 6 attempts, 1 s base, 30 s cap). disconnect closes the WebSocket cleanly and turns off auto-reconnect. It does not delete the instance; the emulator keeps running until its inactivity or hard timeout fires: Delete the emulator 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, let reuseIfExists return the same warm instance on the next call, and delete instances explicitly when you're done. List instances, then delete one, or every instance that matches a label: The inactivity timeout fires when nobody talks to the instance for that long; the hard timeout fires regardless. Set both to your tolerance for orphaned instances. Troubleshooting | Symptom or message | Cause | Fix | |---|---|---| | startActivity returned code -91 from open-url | No installed activity handles the URL scheme. | Install the handler app, or register the scheme in your app. See Open URLs. | | INSTALLFAILEDNOMATCHINGABIS | The APK contains only 32-bit armeabi-v7a code. | Build for x86_64, arm64-v8a, or both. See Supported ABIs. | | UnsatisfiedLinkError at runtime | Some native libraries ship only for arm64-v8a while others also ship x8664, so Android installs the app as x8664. | Ship every native library for the same set of ABIs. | | A split APK app installs but won't start | The base and config APKs were installed as separate apps. | Put the whole set in one initialAssets entry with a plural source. See Split APKs. | | adb devices doesn't list the emulator | The tunnel isn't running, or it stopped. | Run lim android connect --id and keep it running. | | adb devices shows the emulator as offline | The tunnel's WebSocket closed. | Restart lim android connect, or call adb reconnect. | | The instance is gone when you come back | Its inactivity or hard timeout fired. | Pass a longer --inactivity-timeout or spec.inactivityTimeout, up to the 24h maximum. | Next steps Build the APK remotely and install it on this emulator. Point any upstream Appium driver at the ADB tunnel for native or hybrid tests. Let the app on the emulator reach services on your machine or VPN. Stream the live emulator into your web app with `. --- ## Sign and publish URL: https://docs.limrun.com/docs/android/sign-and-publish Produce a Play-ready signed AAB without a keystore on your machine, then publish it to a Google Play track. Sign and publish to Google Play Signing runs on the Gradle sandbox, and Limrun can generate and keep your app's upload key for your organization. No keystore file has to live on laptops or in CI secrets. The build itself works as described in Build with Gradle. Sign with the organization's upload key Google Play accepts uploads only when they are signed with the app's registered upload key, the same key every time. Add --sign and Limrun signs the build with the key it escrows for your organization: On the first --sign build of an app, Limrun generates an upload keystore and stores it as the organization's androidSigningKey secret, named by the Android application ID. Every later --sign build of that app, from any machine, resolves the same key. The build prints which case you are in: --sign makes bundleRelease the default task. An explicit --task list must contain a bundle task, or the build is rejected before an instance is created. The application ID is detected from app.json (Expo) or the first uncommented applicationId in app/build.gradle(.kts). When detection fails, or your build flavors use different IDs, name the key explicitly: The keystore and its passwords reach the build sandbox for the duration of the build only and never appear in the streamed output. Each sandbox serves a single organization and is destroyed with the instance. A successful signed build has already passed the server's signature check on the produced AAB; an unsigned or broken artifact fails the build instead of shipping. Bring your own upload key If Google Play already knows your upload key, pass your keystore instead of --sign: The TypeScript SDK has no escrow shortcut, so every SDK build passes the signing material on the build call. CLI only: the passwords can come from the LIMKEYSTOREPASSWORD and LIMKEYPASSWORD environment variables instead of the command line. CLI only: add --save-key to escrow the provided key, so later builds can drop the keystore flags and use plain --sign. Escrow never overwrites: if a different key is already stored for the app, --save-key fails before any instance is created. To move a key to another organization, run a build there with your keystore and --save-key. Publish to Google Play You can publish in the same command that builds, or publish an AAB that is already in Asset Storage from the Limrun console. Both need these in place first: - The app listing exists in Play Console. Google's API cannot create listings. - The Google identity you publish with has release permission for the app in Play Console. - The AAB is signed with the app's upload key. Google Play registers the upload key from the app's first upload and requires the same key on every upload after that, which is what the escrowed --sign key guarantees. - The AAB's versionCode has never been uploaded to this app before. Publish from the build Add --upload-to-playstore to a signed build. Authenticate with either a service-account JSON key whose email is invited in Play Console, or a short-lived Google OAuth access token with the androidpublisher scope: | Flag | What it does | |---|---| | --upload-to-playstore | Publish the signed AAB to a Google Play track after a successful build. Requires signing plus a service account or an access token. | | --playstore-service-account | Service-account JSON key whose email is invited in Play Console. | | --playstore-access-token | Short-lived Google OAuth token with the androidpublisher scope. Prefer the LIMPLAYSTOREACCESS_TOKEN environment variable. | | --playstore-track | internal (the default), alpha, beta, production, or a custom track ID. Publishing replaces the track's existing releases. | | --playstore-release-status draft\|completed | completed makes the release live on the track; draft commits it without rollout. Required explicitly for the production track. | | --playstore-package | Package name to publish under. Omit it to read the name from the built AAB. | | --auto-version-code | Resolve the next free versionCode from Google Play and stamp it before building. Requires --upload-to-playstore. | --auto-version-code sets the versionCode to one more than the highest already on Google Play, so repeat publishes never collide. It writes expo.android.versionCode for Expo projects, which needs a static app.json, or the single literal versionCode in the conventional app/ module build script for native projects. Computed or flavor-split version codes are rejected when the build is requested. Without the flag, bump versionCode in app/build.gradle(.kts) (Expo: expo.android.versionCode in app.json) before each build. To publish from your own product on behalf of your users, with their Google sign-in in the browser, see Publish to the stores. Publish from the console The console publishes with your own Google account, in your browser. Limrun never stores that Google credential: the browser mints a short-lived access token, and the publish uses it for that one upload. 1. Build and upload a signed AAB: lim gradle build . --sign --upload myapp.aab. 2. Open console.limrun.com and select your organization. 3. On the Secrets page, click Connect Play Console and sign in with the Google account that has release access. The session lives in this browser only; nothing is stored. 4. Go to the Registry page. Assets ending in .aab have a Publish to Play Store action. 5. Click it, enter the Package name (the app's application ID), and publish. The upload targets the internal testing track, and the dialog reports Google's verdict. If a publish reports that the version code already exists, the build may already be live from an earlier attempt. Check Play Console before you bump the versionCode and publish again. Troubleshooting | Build output | Cause | Fix | |---|---|---| | The organization already has a different upload key escrowed for ... | --save-key found an existing, different key for this application ID. | Builds with --sign use the stored key. Drop --save-key to sign with your keystore for this build only, or delete the stored secret first if your keystore is the real upload key. | | Cannot determine the Android application ID for signing | Neither app.json nor app/build.gradle(.kts) yielded an application ID. | Pass --application-id . | | --sign produces a Play-ready signed AAB; include a bundle task | The explicit --task list has no bundle task. | Add bundleRelease to --task, or omit --task. | | signing ... contains an unsupported character | The password or alias contains characters outside ISO-8859-1, which Gradle's properties file cannot carry. | Re-create the key with a Latin-1 password. | | the built AAB carries no signature | Gradle produced a bundle without applying the injected signing configuration. | Retry the build; contact support if it persists. | | Publish reports the version code already exists | That versionCode was uploaded before, possibly by an earlier attempt. | Check Play Console, then bump versionCode or build with --auto-version-code. | Next steps Build a signed AAB on every push with only LIMAPIKEY in the pipeline. Add Google Play and App Store publishing to your own platform. Detach from release builds and receive the result on a webhook. --- ## Build logs and webhooks URL: https://docs.limrun.com/docs/ci/build-logs-and-webhooks Start a remote Xcode or Gradle build without waiting on it, read its logs later, and get the result delivered to your endpoint. Build logs, webhooks, and detached builds A CI job, coding agent, or backend often should not hold a build stream open for minutes. Builds keep running on the sandbox after the caller detaches, and their logs persist after the instance is gone. Xcode builds (lim xcode build) and Gradle builds (lim gradle build) share the same model; every command on this page has an xcode and a gradle form. Detach from a build Detach to return as soon as the build is accepted instead of streaming logs until it finishes: Once the build is accepted, the CLI prints the instance ID, a console URL for the build, and the exact command that retrieves its logs. With --json, the output carries instanceId, execId, the console URL (consoleUrl), and logsCommand, plus webhookUrl when a webhook is configured. A webhook is optional; add one (see Receive a webhook) to get the terminal result without polling. In TypeScript, detach() resolves once the build request is accepted and returns the exec ID. build is the handle returned by xcode.xcodebuild(...) or gradle.gradlebuild(...); see Build with Xcode and Build with Gradle. Build on a short-lived sandbox A detached build normally runs on the sandbox the CLI remembers for your workspace, which stays up until its inactivity timeout. For a one-off build, such as a release job or a publish request from your backend, add --inactivity-timeout so the sandbox deletes itself as soon as the work is done: The flag makes the build create a fresh instance with that lifecycle instead of reusing the remembered one, so it cannot be combined with --id. The timeout is measured from the last activity the server reports, and active builds and uploads count as activity, so even 3s never interrupts the build. The inactivity controller checks about every 15 seconds, so a 3s timeout usually tears the instance down 3 to 18 seconds after its last activity rather than at exactly three seconds. Read build logs Read the latest build on the remembered instance, as a snapshot or following it live. No exec ID is needed: Without flags, logs prints a point-in-time snapshot of the latest build, including one that is still running. A snapshot with status RUNNING does not mean the build succeeded, so check the reported status before you declare success. --follow replays the output and keeps streaming until the build reaches a terminal state. Stopping --follow stops only the observation; the remote build keeps running. The CLI reads the active build from the sandbox first. When there is none, or the sandbox or instance answers 404, it downloads the newest persisted log instead. Persisted logs stay readable after the instance is deleted, and reading logs never creates a replacement instance. Other errors are reported as errors; the CLI never substitutes an older log. Pass --id to read another instance. For Xcode, that can be an Xcode sandbox ID or the ID of an iOS simulator attached to one. To keep inspecting one detached build after newer builds start, run the logsCommand it printed, or pass the exec ID yourself: Exec IDs are optional everywhere. Do not ask a user for one just to read the latest build. Read logs from the TypeScript SDK observeBuildLogs, shown in Read build logs, replays or follows a build while its sandbox still retains the stream. It exists on both the Xcode and the Gradle client. 'latest' reads only the sandbox's retained stream, which can include a completed build; unlike the CLI, it does not fall back to persisted logs. Omit follow for a snapshot, or replace 'latest' with the exec ID returned by detach() to observe one specific build. To read persisted logs, list the completed builds of an instance and download the selected record's downloadUrl: Both lists stay available after the instance is deleted. For a build that ran through an iOS simulator attached to an Xcode sandbox, pass the Xcode sandbox ID, not the simulator ID. Receive a webhook Pass a webhook URL when something needs the terminal result without watching the live stream. Limrun POSTs to it once the build reaches SUCCEEDED, FAILED, or CANCELLED, whether the caller keeps streaming or detaches: The webhook flags on both commands: | Flag | What it does | |---|---| | --webhook-url | HTTPS endpoint that receives the terminal result. Defaults to LIMWEBHOOKURL. | | --webhook-header NAME=VALUE | Header set verbatim on the request, such as an Authorization secret. Repeatable, at most 16. Defaults to LIMWEBHOOKHEADERS (comma-separated; write a literal comma as \,). | | --webhook-label KEY=VALUE | Label echoed verbatim in the payload's labels field so your endpoint can correlate the callback with its own context, such as pipeline=release or commit=$GITSHA. Repeatable, at most 32; key and value at most 64 printable ASCII characters each. Defaults to LIMWEBHOOK_LABELS (comma-separated; write a literal comma as \,). | Header and label flags require --webhook-url. In CI, the environment variables keep the command line short: Combined with --detach, a webhook turns a build into a fire-and-forget job: Payload The payload carries the build result and debugging links: logsUrl is a time-limited link to the persisted plain-text build log. When status is FAILED, read that log for diagnostics. There is no error field: neither xcodebuild nor Gradle exposes one deterministic diagnostic, so the log is the answer. The instance, console, log, timing, and exit-code fields are omitted when they are unavailable, and labels appears when the build set --webhook-label. App identity in the payload The payload also describes the app the build produced, so a receiver can tell builds apart without downloading the artifact. In a monorepo that ships several apps, key your bookkeeping on the bundle identifier or application ID. The fields come from the built bundle's Info.plist: | Field | Carries | |---|---| | bundleIdentifier | CFBundleIdentifier, for example com.example.myapp. | | shortVersion | CFBundleShortVersionString, for example 1.2.3. | | buildVersion | CFBundleVersion, for example 42. | | displayName | CFBundleDisplayName, falling back to CFBundleName. | | deeplink | The app's primary URL scheme from CFBundleURLTypes, for example myapp for myapp:// links. | | assetId | The Asset Storage asset the artifact was uploaded to. Use it to install exactly the build a webhook reported. | | iconUrl | A time-limited link to the app icon PNG stored next to the asset. | These fields are set on successful builds that produced one app bundle. A lim xcode test run builds for testing and produces an xctestrun tree rather than a single app, so it carries none. assetId and iconUrl also need --upload, which creates the asset; --signed-upload-url (which defaults to LIMSIGNEDUPLOAD_URL unless --upload names a destination) puts the artifact in your own bucket and mints no asset. applicationId, versionName, and versionCode come from the built APK's or AAB's manifest, not from your Gradle scripts. They reflect the merged manifest: product flavors, build-type suffixes, and manifest placeholders resolve the way the shipped artifact has them. These fields are set on successful builds that produced an artifact, which means the request also asked for an upload (--upload or --signed-upload-url) or a Play Store publish. A build that packaged nothing has no artifact to describe. versionCode is omitted when the manifest declares none. Reading the app identity is best-effort on both platforms: when Limrun cannot read the bundle or manifest, it leaves the fields out rather than failing a build that compiled, signed, and uploaded fine. Delivery rules - The URL must use HTTPS on a public DNS host. IP literals and private or cluster-internal targets are rejected when the build request is submitted. - At most 16 headers are accepted. Host, Content-Length, Transfer-Encoding, and Connection cannot be overridden. - Any 2xx response counts as delivered. Other responses and connection failures are retried up to two more times. - Delivery is best-effort. Exhausting the retries does not change the build result. - Cancelled builds send a callback too. A new build on the same sandbox cancels the active one. For a complete receiver that authenticates callbacks with a per-build token, see Publish to the stores. Next steps Complete workflows for signed IPAs, Bazel builds, and signed AABs on Linux runners. Post a live preview link on every pull request. Every lim xcode build flag, sync rules, and artifact uploads. Remote Gradle builds, Expo prebuild, and artifact uploads. --- ## GitHub Actions recipes URL: https://docs.limrun.com/docs/ci/github-actions Copy-ready workflows that build signed iOS and Android apps on Limrun from a Linux runner. GitHub Actions recipes Every build on this page runs on a standard ubuntu-latest runner. The Macs, the Xcode install, the Android SDK, and the JDK live on Limrun's side, so the runner only needs Node to install the lim CLI and a LIMAPIKEY secret. No runs-on: macos-latest, no Apple-licensed CI runner. Add the key under Settings, Secrets and variables, Actions in your repository as LIMAPIKEY. Signing secrets that a recipe needs are listed with it. Build a signed IPA on every pull request This job signs a device build with a p12 certificate and a provisioning profile, then uploads the IPA to Asset Storage. Store the certificate and profile as base64 secrets (P12BASE64, PROFILEBASE64) along with P12_PASSWORD: What the workflow does: 1. Checks out the PR's code on an Ubuntu runner. 2. Installs the lim CLI with npm. 3. Decodes the signing certificate and provisioning profile from the base64 secrets onto the runner's filesystem. The secrets reach the script through env:, never interpolated into the script body. 4. Creates an Xcode sandbox labelled with the repository and PR number, so the cleanup step can find it. If you drop the cleanup step, the same labels and --reuse-if-exists let later pushes to the PR reuse the warm sandbox. 5. Runs a signed device build and uploads the IPA under a name tied to the PR number. The CLI prints the artifact download URL to the job log. 6. Deletes the sandbox by label, even when the build fails. Drop this step if you would rather keep a warm sandbox between pushes and let its inactivity timeout clean it up. To sign without a p12 at all, or to upload straight to TestFlight, swap the signing flags for cloud signing and --upload-to-appstore; see Sign and distribute. Build a Bazel workspace For Bazel workspaces, start the remote build execution endpoint, build with the printed command, upload the result, and stop the stack: lim xcode rbe upload publishes the latest successful build before the cleanup step runs. lim xcode rbe --stop stops only the tunnel and the remote stack, so the step also deletes the Xcode sandbox, even when the build fails. Prefer it over --auto-upload in CI: an automatic upload runs after the build returns, and stopping the stack right away can cancel it. Keep --digest_function=sha256 in front of build; Build with Bazel explains why and covers the generated --config=limrun. Build a signed AAB on every push This job builds a release AAB signed with your organization's escrowed upload key, so no Android SDK, JDK, or keystore exists anywhere in the pipeline: What the workflow does: 1. Checks out the code on an Ubuntu runner. 2. Installs the lim CLI with npm. 3. Runs a signed release build on a Limrun Gradle sandbox and uploads the AAB under a name tied to the commit. The CLI prints the asset name and a signed download URL, which expires after 15 minutes. 4. Deletes the Gradle sandbox the build used, even when the build fails. To download the AAB later, ask Asset Storage for a fresh signed URL: The only secret in the pipeline is LIMAPIKEY. The upload keystore never exists on the runner, so there is nothing to rotate if a CI provider is compromised. Sign and publish covers how --sign escrows the key and how to bring your own. Run tests and previews Test and preview workflows live next to the tools they run: - PR previews: build every pull request and comment with a preview link, including cleanup when the PR closes. - XCTest: lim xcode test . exits non-zero when any test fails, so it works as a CI step with no output parsing. - Maestro and Appium: complete Linux runner workflows for each framework. Delete the instances a job creates in an if: always() step, by label as the IPA recipe does or as the last instance of its type as the other recipes do. Inactivity timeouts clean up after jobs that crash before that step runs. Next steps Detach from long builds and receive the result on your own endpoint. A live preview link on every pull request. Cloud signing, extensions, and TestFlight uploads. Escrowed upload keys and Google Play publishing. --- ## PR previews URL: https://docs.limrun.com/docs/ci/pr-previews Give every pull request an iOS build that reviewers open in the browser from a link on the PR. Automatic PR previews with GitHub Actions PR previews work like web deploy previews for an iOS app. Previews cover iOS apps only. The packaged Action runs on GitHub Actions, and the underlying lim xcode build --upload pattern works on any CI system. How a preview link works A preview link points the Limrun console at an asset in Asset Storage: Opening it provisions a fresh simulator with that build pre-installed. Reviewers need no install and no Mac, but they do need to be members of your Limrun organization. Any build that uploads to Asset Storage produces a previewable asset. From a shell or a coding agent, build with --upload and share the resulting URL: Bazel workspaces publish the same kind of asset with lim xcode rbe upload or lim xcode rbe --auto-upload ; see Build with Bazel. Use the packaged Action limrun-inc/ios-preview-action wraps the create, build, upload, and PR comment steps into one. Its post: step deletes the Xcode sandbox after every run, so no sandbox outlives the job. Add LIMAPIKEY under Settings, Secrets and variables, Actions in the GitHub repository, then add this workflow: The Action posts a comment titled Limrun Preview with a table of the platform, the short commit SHA, and the preview link, and updates that same comment on later pushes. With closed in on.pull_request.types, the run on merge or close deletes the PR's Xcode sandboxes and changes the comment to say the preview was removed. The preview asset itself stays until its TTL expires, 14 days after the last upload by default. | Input | What it does | |---|---| | api-key | Required. Your Limrun API key, stored as a repository secret. | | project-path | Directory to sync to the Xcode sandbox. Defaults to the repository root. A Bazel workspace root here switches to remote build execution. | | project | Path to the .xcodeproj to build. Derived from the workspace file when omitted. | | workspace | Path to the .xcworkspace to build. Derived from the project file when omitted. | | scheme | Scheme to build. Derived from the project file when omitted. | | sdk | SDK to build. Defaults to iphonesimulator. | | model | Simulator model for previews: iphone (default) or ipad. | | xcode-version | Xcode major to build with, such as 27. Switching majors invalidates the other version's build state, so the next build starts cold. Defaults to the sandbox default. | | build-settings | Newline-delimited KEY=VALUE Xcode build settings. Keys follow the same rules as --build-setting; see Pass build settings. Use $(inherited) to append. | | bazel-target | Bazel label of the app target, such as //App. Inferred for single-app workspaces. | | github-token | Token for posting the PR comment. Defaults to github.token. | | media | Newline-delimited image or video paths to show in the PR body. Add #alt text after an image path. Requires media-token and gh 2.99.0 or newer on the runner. | | media-token | User token (personal access token or OAuth) with write access to the repository, used only to upload media. GitHub rejects github.token for attachments. | The Action sets two outputs: preview-url and asset-name. Its action.yml also declares asset-id, but the Action never sets it, so that output is always empty. Bazel projects The same Action handles Bazel workspaces. When project-path is a Bazel workspace root (it contains MODULE.bazel, WORKSPACE, or WORKSPACE.bazel directly), the Action builds with remote build execution instead of xcodebuild and uploads the built app as the preview asset. The runner needs bazelisk on the PATH; GitHub-hosted runners have it preinstalled. Set bazel-target, for example //App, when the workspace has more than one app target; a single-app workspace is inferred: The xcodebuild-only inputs (project, workspace, scheme, build-settings) fail the run in Bazel mode. Put your Bazel flags in user.limrun.bazelrc at the workspace root instead. Write a custom workflow Write the workflow yourself when you need a different comment format, your own asset naming, or a CI system other than GitHub Actions. The CLI runs anywhere Node runs, so the same lim xcode build --upload pattern works on GitLab CI, CircleCI, Buildkite, and Jenkins. This workflow builds on every PR open or push, uploads under a PR-scoped asset name, and comments with the link. Replace MyApp with your scheme. Each run adds a new comment like this one: The workflow: What each step does: 1. Checkout. The source tree is what gets synced to the Xcode sandbox. 2. Install Limrun CLI. The build runs on Limrun's Macs, so the job stays on a Linux runner. 3. Build on Limrun. lim xcode create --reuse-if-exists creates or reuses a sandbox labelled with the PR number and repository, so later pushes to the same PR land on the same sandbox. lim xcode build . --upload syncs the source, runs xcodebuild, and uploads the artifact. Naming the asset after the PR means each push overwrites it, so the link always shows the latest commit. Build uploads expire 14 days after the latest upload by default, so the preview outlives the PR's last activity by 14 days; change the window with --upload-ttl . 4. Comment with preview link. The script builds the preview URL and posts it on the PR. Clean up when a PR closes The packaged Action deletes the sandboxes on its own, but leaves the preview asset to expire by TTL. For the custom workflow, add closed to on.pull_request.types and a job that deletes everything labelled with the closed PR: With the build job guarded the same way (if: github.event.action != 'closed'), one workflow file handles both. Next steps Every lim xcode build flag and artifact upload option. Signed IPAs, Bazel builds, and signed AABs from Linux runners. How preview assets are stored, named, and expired. Gate previews on your test suite with lim xcode test. --- ## Concepts URL: https://docs.limrun.com/docs/concepts Learn the model every Limrun guide builds on: instances, credentials, lifecycle, labels, placement, and assets. Concepts Limrun has a small model: you create instances, each instance gets its own endpoints and token, and instances live until you delete them or a timeout fires. Read this page once and the guides will make sense in any order. Instances An instance is anything you create through Limrun. There are four kinds, each with its own CLI topic and SDK resource: | Instance | CLI | TypeScript SDK resource | |---|---|---| | iOS simulator | lim ios | iosInstances | | Android emulator | lim android | androidInstances | | Xcode sandbox | lim xcode | xcodeInstances | | Gradle sandbox | lim gradle | gradleInstances | Each instance has an ID, a state, and a status block with the URLs you use to reach it. Instances are single-tenant: only one client should drive a simulator or emulator at a time, so scope them per user, per pull request, or per agent session. Build instances and device instances are separate on purpose. You create an Xcode sandbox, build on it, and attach a simulator when you want to run the result. Attaching installs the latest successful build, and every later successful build installs and relaunches on the attached simulator. You pay only for what is running: drop the simulator between test runs and keep the sandbox, or the other way around. Building before the simulator exists also keeps an idle simulator from reaching its inactivity timeout during a long build. Control plane and instance endpoints Limrun has one control plane and a set of endpoints per instance. - The control plane at https://api.limrun.com creates, lists, gets, and deletes instances and manages assets. It authenticates with your organization's API key. - Each instance exposes its own endpoints once it is ready: a device or build API (status.apiUrl), a WebSocket for streaming (status.endpointWebSocketUrl), an MCP server on simulators and emulators (status.mcpUrl), and an ADB endpoint on Android emulators. They authenticate with that instance's token. The full list of status fields is in Instance spec and status. Credentials Five credentials show up in Limrun integrations. Hand each one only to the party that needs it. | Credential | What it can do | Who should hold it | |---|---|---| | API key (LIMAPIKEY) | Create, list, and delete every instance in the organization, and manage assets. | Your machine, your CI secrets, your backend. Never a browser. | | Instance token (status.token) | Drive one instance through its endpoints, only while it is running. Returned only to credentials that can control the instance. | Whoever drives that instance: a device client, `, an MCP client, or a sandboxed agent. | | Stream token (status.streamToken) | Open the instance's streaming WebSocket (status.endpointWebSocketUrl) and nothing else. | A browser that only streams the device, while the instance token stays on your backend. | | Signed stream URL (status.signedStreamUrl) | Open the instance in the Limrun console's streaming view, with the instance token in the URL fragment. | Anyone you want to watch or use the device: a reviewer, a teammate, a Slack thread. | | Scoped token | Reach specific registry endpoints, such as device installs or the Apple relay, for a limited time. | A browser session started by your backend. See scoped tokens. | Create API keys in the console under Settings, API Keys. The CLI reads LIMAPIKEY from the environment or a .env file in the working directory, or uses the key stored in ~/.lim/config.yaml. Lifecycle and timeouts Every instance moves through the same states: creating means hardware is being provisioned, assigned means it is booting, and ready means every URL in status works. An instance ends in terminated when you delete it, when a timeout fires, or when it fails; a failure sets status.errorMessage. Pass wait: true in the SDK (the CLI always waits) to get the instance back only once it is ready. Two timeouts bound an instance's life: - Inactivity timeout deletes the instance after a period without activity. Simulators, emulators, and Xcode sandboxes default to your organization's setting for that platform, and Gradle sandboxes to 10 minutes. Override it per instance with a duration such as 10m or 3h. - Hard timeout deletes the instance after a fixed wall-clock time regardless of activity. The default 0 means no cap. Delete instances explicitly when the work is done. The timeouts are the safety net for crashed jobs and forgotten sessions. Labels and reuse Labels are free-form key=value pairs you attach at create time. They do three jobs: - Reuse. With --reuse-if-exists (reuseIfExists: true), a create call returns an existing instance whose labels match exactly instead of provisioning a new one. Retries and repeated CI runs land on the same warm instance. - Listing. --label-selector (labelSelector) filters lists with a comma-separated key=value list. - Cleanup. Delete everything tagged with a pull request number or a user ID in one loop. Reuse matching runs in the region that handles the create call. Placement follows the caller's location and capacity, so a create from a different network can land in another region, miss the match, and start a fresh instance. Give each task its own label, such as --label issue=. Reuse needs at least one label: without labels, --reuse-if-exists is ignored and every call creates a new instance. Useful label keys are tenant, user, session, pr, repo, agent, and managed_by. The CLI's default instance When you omit --id, CLI commands target the instance of that type you created last. The CLI records it per workspace in ~/.lim/last-instances.json. A workspace is the current git repository or worktree by default, so agents working in separate worktrees never drive each other's instances. Assign any other directory its own workspace with lim set-workspace-dir, or set one explicitly with --workspace or LIMWORKSPACE. On lim xcode build and lim xcode test, --workspace names the .xcworkspace instead, so use LIMWORKSPACE. Scripts and agents that create several instances should pass --id every time. Placement Limrun schedules each instance into a region. You influence placement three ways: - Jurisdiction (--jurisdiction, spec.jurisdiction: us, eu, or as) is a hard constraint. Creation fails when no region in the jurisdiction has capacity. - Clues (spec.clues) express a preference. Pass an end user's IP (ClientIP) or coordinates (ClientLocation) so an embedded device runs close to the person using it, or an Android OS version (OSVersion). - Region (--region, spec.region) is deprecated. It is a preference, not a pin: the request overflows to other regions when the preferred one is full. Details and payloads are in Instance spec and status. Assets Asset Storage holds the binaries instances install: iOS simulator builds (.app folders zipped or tarballed), signed IPAs, Android APKs and AABs, and encrypted keychain snapshots. Upload once, then install an asset by name when an instance boots, push it to a running instance, or share it as a browser preview. Build commands upload to Asset Storage with --upload`. Next steps Give an agent the CLI and skills to build and run your app. Create a simulator and drive it from the CLI or SDK. Stream a live device into your own product. Every create field, status field, and state. --- ## Bazel iOS app with Claude Code URL: https://docs.limrun.com/docs/guides/ios-bazel-claude-code-web Copy a Bazel iOS template, have Claude change the app, and review the change on a cloud simulator and in a pull request preview. Build an iOS app with Bazel from Claude Code on the web Every Apple build action runs on Limrun's remote Macs through Bazel remote build execution, so neither you nor the session VM needs a Mac or Xcode. Before you start - A Claude Code on the web environment set up as described in Claude Code on the web. This guide calls it limrun. - The Limrun API key you added to that environment. The template's preview workflow uses it too. - A GitHub account. The GitHub CLI is optional; it makes the secret step one command. Open limrun-inc/ios-bazel-template, click Use this template, then Create a new repository. The template is a small SwiftUI chat app built with rulesapple and rulesswift. Its assistant replies are canned, so it needs no network or API key of its own. Your copy includes an iOS Preview workflow that builds every pull request and comments with a preview link. GitHub copies the workflow but not the secrets, so add the key yourself. The command prompts for the value: Without the GitHub CLI, add it under Settings, Secrets and variables, Actions in your copy. The session VM doesn't ship bazelisk. Edit the limrun environment and add @bazel/bazelisk to the install line of its setup script: Start a new session on your copy with the limrun environment, and send: Following the limrun-xcode-bazel skill, Claude runs lim xcode rbe to start a remote build stack, attaches a simulator, and builds with the command the CLI prints. The first build took about a minute in our run, with the Apple actions executing on the remote Macs. The successful build installs the app on the attached simulator, and Claude replies with a screenshot and a Live simulator link. Open the link to use the app in your browser: Keep the simulator open and ask for a visible change: Claude edits the Swift source and the asset catalog, then rebuilds. Bazel reuses the cached actions from the first build, so the rebuild took under 10 seconds in our run. Claude also commits the change and pushes it to a new branch. The app reinstalls and relaunches on the simulator you have open: Click Create PR in the session bar. The pull request opens under your GitHub account, which starts the iOS Preview workflow. When the workflow finishes, about two minutes in our run, it comments on the pull request with a preview link: Open Preview runs the build from the pull request on a simulator in the Limrun console. Reviewers need to be members of your Limrun organization to open it. Clean up The Xcode sandbox and the simulator delete themselves after your organization's inactivity timeout, so closing the session is enough. To stop them right away, ask Claude to clean up before you close the session: lim xcode rbe --stop stops the build stack, and lim xcode delete and lim ios delete delete the instances. Use your own Bazel project The same flow works on your own Bazel workspace once the limrun environment has bazelisk. Start from Build with Bazel for what lim xcode rbe configures, and copy .github/workflows/ios-preview.yml from the template to get preview links on your own pull requests; PR previews covers its options. Next steps Remote build execution, uploads, and CI for Bazel workspaces. Preview links on every pull request, for Bazel and xcodebuild projects. --- ## Build with Bazel URL: https://docs.limrun.com/docs/ios/build-with-bazel Build a Bazel iOS workspace from any machine without a local Xcode install, then run or share the app. Build with Bazel If your iOS app builds with Bazel (rulesapple, rulesswift), point it at Limrun's remote build execution (RBE) backend instead of syncing sources. Bazel keeps running on your laptop or CI runner, and every Apple action (Swift compilation, asset catalogs, codesigning, bundling) runs on Limrun's Macs. Linux, Windows, and macOS clients all work, and none of them needs Xcode. The loop has three steps: 1. Start the RBE endpoint with lim xcode rbe. It provisions an Xcode sandbox, starts the remote execution stack on it, and bridges it to a local port. 2. Run the printed build command. Bazel sends actions through the tunnel, and results land in the remote cache. 3. Run, upload, or preview the built app: install it on an attached simulator, or publish it as an asset. Start the endpoint You need the lim CLI and an API key; the Quickstart sets up both. From the root of your Bazel workspace (the directory with MODULE.bazel or WORKSPACE), start the endpoint: The command sets everything up and prints the exact build command for your workspace, for example: Run it. The tunnel keeps running in the background, so from here on you only build. lim xcode rbe did three things: - Provisioned an Xcode sandbox, or reused your most recent one, and started the RBE stack on it. - Opened a tunnel so the endpoint is reachable at grpc://127.0.0.1:8980. Change the port with --port. - Generated a .limrun/ directory in your workspace with the --config=limrun Bazel configuration matched to the sandbox's Xcode version, and wired a try-import into your .bazelrc. To build with another installed Xcode, pass --xcode-version for the newest GA release of that major, or --xcode-version to pin that exact version, which may be a beta. See Xcode and tool versions. Do not hand-edit .limrun/; the next lim xcode rbe regenerates it. To add your own flags to the Limrun build path, put them in user.limrun.bazelrc at the workspace root. The generated config imports it last, so your build:limrun --... lines win and survive regeneration. Keep --digest_function=sha256 in front of build, exactly as the printed command has it. The Limrun cache is SHA256-only while Bazel 9 defaults to BLAKE3, and the flag is a startup flag, so it cannot live inside --config=limrun. Run it on a simulator lim xcode rbe is build-only until you attach a simulator. Attach one at startup with --ios, or at any point later: With a simulator attached, every successful --config=limrun build installs and relaunches the app on it. There is no separate install step, and attaching also installs the last successful build immediately. Upload builds as assets To publish builds to Asset Storage for PR previews, installs on other simulators, or CI artifacts, arm uploads when you start the tunnel: Every successful build then refreshes the preview/my-app asset, with no post-build step. Upload results are written to .limrun/rbe.log. To upload a single build after the fact instead: TTLs are optional Go durations (24h, 30m). Each upload without one pushes the asset's expiry to 14 days from that upload. Anyone in your organization can then open the build in a browser at https://console.limrun.com/preview?asset=preview/my-app&platform=ios. To build on every pull request from CI, see the Bazel workflow in GitHub Actions recipes. For preview comments on pull requests, limrun-inc/ios-preview-action detects a Bazel workspace and does the build, upload, comment, and cleanup in one step; see PR previews. Stop the endpoint Stop the tunnel and the remote stack, then delete the sandbox when you are done with it: The tunnel runs as a background process by default. Pass --no-daemon to keep it in the foreground, for example while debugging. Limits and behavior - The built .ipa does not land on your machine. The generated config carries --remotedownloadoutputs=minimal. Bazel prints its usual Target //App:App up-to-date: .../App.ipa line, but the artifact stays in the sandbox's cache, where auto-install and uploads read it. If you need the file locally, add --remotedownloadoutputs=toplevel to the build command. - Run lim xcode rbe from the workspace root, not a subdirectory. It writes .limrun/ there and fails otherwise. - Re-run after an Xcode change. The generated config pins the sandbox's Xcode version. After a fleet upgrade or an --xcode-version switch, run lim xcode rbe --stop and then lim xcode rbe to refresh it. - Changing --auto-upload needs a restart. The CLI refuses to re-arm a running tunnel with a different upload configuration rather than silently ignoring it. Run --stop, then start again. Next steps Post a live preview link on every pull request, for Bazel and xcodebuild projects alike. Drive the simulator your build installs onto: taps, screenshots, recordings, logs. Change a Bazel template app with a cloud agent and review it in a pull request. The xcodebuild path for projects without Bazel. --- ## Build with Xcode URL: https://docs.limrun.com/docs/ios/build-with-xcode Compile iOS, watchOS, tvOS, and visionOS apps on a cloud Mac from Linux, Windows, or macOS, and install the result on a remote simulator. Build with remote Xcode lim xcode build syncs your project to an Xcode sandbox, a Mac in the cloud, runs xcodebuild there, and streams the logs back. Your laptop or CI runner does not need to be a Mac. A successful build installs on an attached simulator, uploads to Asset Storage, or becomes a signed IPA. If your app builds with Bazel, use Build with Bazel instead: Bazel keeps running on your side and there is no source sync. The build loop has four steps: 1. Provision an Xcode sandbox. 2. Sync your source folder to it. 3. Run xcodebuild on the remote Mac while the logs stream back. 4. Install or ship. Successful builds install on an attached simulator, or you upload the artifact. Pick your interface The CLI covers the whole build surface. The SDKs cover it as follows: | Surface | CLI | TypeScript | Python | Go | |---|---|---|---|---| | Provision an Xcode sandbox | ✓ | ✓ | ✓ | via REST | | Sync source and run xcodebuild | ✓ | ✓ | not in SDK | not in SDK | | Run one-shot project commands | ✓ | ✓ | not in SDK | not in SDK | Python provisions with xcodeinstances.create (see Provision the sandbox yourself). Go provisions over REST (POST /v1/xcodeinstances) or with lim xcode create. Then drive the build with lim xcode build. The SDK capability matrix lists every surface by language. Build your project Run the build from the project directory: The first run creates an Xcode sandbox, or reuses the one remembered for your workspace. It syncs the directory, runs xcodebuild remotely, and streams the logs. For workspaces or multi-scheme projects, name the scheme and the workspace or project: lim xcode build is the only build command you need. It syncs before every build, so do not run a separate sync step or local xcodebuild. Provision the sandbox yourself lim xcode build creates a sandbox on demand. Create one explicitly when you want labels, a specific lifecycle, or the instance before the first build: The sandbox and any simulator you attach are separate instances with their own lifecycles; see Concepts. TypeScript only: createClient opens a client for the sandbox. The xcode handle it returns is what the sync and build examples on this page use. The Python SDK has no sync or build client, so build on a Python-created sandbox with lim xcode build --id . If another process created the instance and you only have its URL and token, open the client from those: When you know you will test on a simulator right after the build, create both instances and attach them in one call with lim ios create --xcode or lim xcode create --ios. They stay separate instances; the flag saves the attach step described in Install on a simulator. Reuse builds across instances Create the sandbox with --snapshot-key myapp-main to restore and later save its build workspace, and run lim xcode delete --wait-snapshot after building to publish it for the next instance. See Disk snapshots for the full workflow, branch fallbacks, and SDK usage. Install on a simulator Attach a simulator to the Xcode sandbox at any time. The attach installs the latest successful build immediately, and every later successful build installs and launches on it. lim ios create --attach creates a simulator and attaches it in one step; lim xcode attach-simulator attaches one that already exists: Python and Go have no attach call; attach with an HTTP call instead: POST {xcode status.apiUrl}/simulator with the Xcode sandbox's status.token as the bearer token and the body {"apiUrl": "", "token": ""}. Attach a different simulator to test the same build on another device model. Run a simulator covers everything you can do with the running app. Move off the embedded Xcode sandbox Older clients created the Xcode sandbox inside the iOS instance with spec.sandbox.xcode.enabled and read its URL from status.sandbox.xcode.url. The TypeScript SDK (from 0.54.0) and the lim CLI (from 0.35.0) no longer offer that. The API still accepts it, so older SDK and CLI versions keep working. After upgrading, you see one of these: - TypeScript: 'sandbox' does not exist in type 'Spec' on iosInstances.create, or Property 'sandbox' does not exist on type 'Status' where the code reads status.sandbox.xcode.url. - CLI: Expected an Xcode instance (sandbox...), got ios... when an ios_ ID is passed to a lim xcode command. A paired target the previous CLI remembered is ignored, so the next lim xcode build creates a new Xcode sandbox. Create the Xcode sandbox on its own and attach the simulator to it: Pass the Xcode sandbox's sandbox ID to lim xcode commands and the simulator's ios ID to lim ios commands. The two instances have separate lifecycles: delete each one, or let each time out on its own. Sync your source Every lim xcode build syncs first. The first sync uploads everything; later syncs send only what changed. For a continuous sync without building, for example while a tool watches the remote workspace, use watch mode: TypeScript only: sync keeps watching the folder by default (watch defaults to true) and installs after each sync (install also defaults to true). Pass watch: false for a one-shot sync. What gets synced The sync always skips .git, .DS_Store, and the basis cache directory. It also skips these paths at the project root (nested copies are left to your nested .gitignore files): - Build outputs: build/, .build/, DerivedData/, Index.noindex/, ModuleCache.noindex/, .index-build/. - Dependency caches: .swiftpm/, Pods/, Carthage/Build/. Anything under xcuserdata/ or ending in .dSYM/ is skipped at any depth. Paths matched by your .gitignore files are skipped too, root and nested alike, with git's usual precedence. .xcconfig and .env files are synced even when .gitignore matches them, because the remote build needs them; exclude them explicitly with --ignore if you must. The force-include cannot reach a file whose parent directory is ignored, because the sync prunes that directory; use --include with a pattern that also matches the directory. If a file is both tracked by git and ignored, the sync warns you so you can fix the mismatch. Symlinks sync as symlinks when their target is a relative path inside the synced folder, so setups that link shared sources into an app directory build the same remotely as locally. Symlinks with absolute targets are skipped with a warning. Relative links that escape the synced folder fail the sync; pass --ignore to skip them instead. Dependency caches do not need to ship. The sandbox detects Podfile, Package.swift, or Cartfile and resolves dependencies on its side before the build. Keep them in your local .gitignore as usual. Exclude or force-include paths To exclude something the sync does not already skip, pass a regular expression with --ignore. Repeat the flag for several patterns. --include works the other way: it force-syncs paths that .gitignore, a built-in rule, or --ignore would exclude. When a path matches both flags, --include wins. Use it when a local codegen step produces gitignored files the build needs: Both flags take regular expressions, not gitignore syntax; TypeScript takes predicates instead of patterns. An excluded parent directory prunes its whole subtree, so to reach files inside it the pattern must also match the directory path itself, as the --include examples do (^ios/, not GeneratedKit/). The basis cache is never included. Tools that need a Git repository Some project tools refuse to run outside a Git repository. The sync excludes .git, so pass --git-init to create a repository in the synced workspace before project generation, dependency resolution, and xcodebuild: Add files from outside the repository When the build needs files that are not in your repository, such as a .netrc for private packages, a CI-only Config.swift, or a secrets file, sync them alongside the source. Each entry maps a local path to a remote path: Remote paths that start with ~/ expand to the sandbox's home directory. Keep the delta cache on CI The basis cache is the local copy of the last sync that lets later syncs send only changes. It defaults to your OS temp directory, which CI runners wipe between jobs. Pin it to a persistent path with --basis-cache-dir (basisCacheDir in the SDK), for example /var/cache/lim. Generated Xcode projects If XcodeGen generates your .xcodeproj and the project is gitignored, which is the recommended XcodeGen setup, there is nothing to configure. The sandbox generates the project from project.yml before each build: The spec can sit at the repository root or one directory down, like a monorepo's ios/. Both are found without flags. Try it on sample-xcodegen-app. If your spec has another name or location, pin it. The three flags mirror xcodegen generate --spec, --project, and --project-root, with paths relative to the synced folder root: | Flag | What it does | |---|---| | --xcodegen-spec | The spec file. Default: project.yml at the root. | | --xcodegen-project | The directory the project is generated into. Default: the spec's directory. | | --xcodegen-project-root | The directory the spec's relative paths resolve against. Default: the spec's directory. | Passing any of these flags always regenerates the project, even when the sync supplied one. Without them: - The sandbox generates only when you did not supply the project. A committed .xcodeproj, or one you force-sync, always wins and is never modified. - The project regenerates on every build, so editing project.yml locally and rebuilding works. No stale project lingers on a reused sandbox. - Codegen steps beyond xcodegen generate, such as a Makefile that produces a local Swift package or config-derived sources, still run on your side before the sync. If their output is gitignored, force-sync it with --include. - The sandbox uses the selected XcodeGen tool. Select its compatibility line with lim xcode use xcodegen@2, or pin an exact release with a sandbox mise override; see Xcode and tool versions. - If project.yml expands environment variables (${VAR}), the build log warns you: expansion uses the sandbox's environment, not yours. Run project commands Use lim xcode run when the repository needs a macOS command, such as a Make target or a code generator. It syncs the current directory, then runs the command in the remote workspace: The CLI runs the command after --. In TypeScript, call sync before run, as the example does. The optional positional path sets the remote working directory, relative to the synced workspace. It defaults to .: Add --no-sync when the sandbox already has the source state the command needs, and --env KEY=VALUE to set variables: Commands are one-shot. They stream stdout and stderr, return the remote exit code, and do not provide an interactive terminal. --timeout (timeoutSeconds in TypeScript) sets the server-side limit in seconds: 3600 by default, 21600 at most. Run xcodebuild from the SDK The TypeScript SDK's xcodebuild starts the build on a synced sandbox and streams its output: The call exposes three event-emitter channels while the build runs: | Channel | Carries | |---|---| | command | The full command string the sandbox ran. One event, then it closes. | | stdout | xcodebuild stdout as the build runs. | | stderr | xcodebuild stderr. | Awaiting the call resolves to a result with exitCode, status (SUCCEEDED, FAILED, or CANCELLED), and a signedDownloadUrl when you asked for an upload. The xcodebuild settings match what you would pass locally: | Field | Type | Meaning | |---|---|---| | workspace | string | Path to a .xcworkspace, such as MyApp.xcworkspace. | | project | string | Path to a .xcodeproj. Use this or workspace, not both. | | scheme | string | Required for multi-scheme apps. | | sdk | string | iphonesimulator (default), iphoneos, watchsimulator, watchos, appletvos, or xros. tvOS and visionOS are device builds only. | To return before the build finishes, or to receive the result on a webhook, see Build logs and webhooks. Pass build settings Pass custom build settings with a repeated --build-setting KEY=VALUE, or with buildSettings in TypeScript. Keys must be uppercase letters, digits, and underscores (^[A-Z0-9]+$), and the server reserves a few managed keys. A build takes at most 32 settings, 4096 bytes per value, and 64 KiB in total. Your values apply after Limrun's managed settings and replace a managed value with the same key, so a project that needs to can override a default such as ONLYACTIVEARCH. APPCONFIG_* values are treated as app configuration and redacted from build logs. This build passes a staging API URL and a compilation condition: Build settings reach xcodebuild only. --env KEY=VALUE sets an environment variable for every remote build command instead: dependency installs, expo prebuild, pod install, and xcodebuild. Server-managed variables such as PATH cannot be overridden. Upload the build artifact Upload the artifact to Asset Storage when you want to install it on other simulators, open it as a PR preview, or keep it as a CI artifact: Later creates can install it by name with lim ios create --install-asset my-build.zip. The uploaded asset expires 14 days after the last upload by default: each build upload pushes the expiry out again, so assets you keep rebuilding stay alive. Pass --upload-ttl with a Go duration (ttl in the SDK) such as 720h to change the window; 1d is not a valid Go duration. Assets uploaded with lim asset push never expire unless you give them a --ttl. Asset Storage covers the rest of the asset model. To put the artifact in storage you already manage, pass a presigned S3, GCS, or R2 URL. The sandbox PUTs the artifact to it directly, so the bytes never travel through the machine that called lim xcode build. This also saves pulling a large build back through your CI runner: When the product name differs from the scheme The server finds the built .app on its own, including projects where the scheme and PRODUCT_NAME diverge, such as a scheme "MyApp Dev" that builds MyApp-dev.app. If an upload still fails with built artifact not found, name the bundle file, including the .app extension. The server then takes that name from the build products verbatim and skips discovery: Build flag reference These are the lim xcode build flags. The flags every command shares (--api-key, --json, --quiet, --[no-]create, --[no-]daemon) are in the CLI reference. Run lim xcode build --help for the full text of each. | Flag | What it does | |---|---| | --scheme | Xcode scheme to build. | | --workspace | .xcworkspace to pass to xcodebuild. | | --project | .xcodeproj to pass to xcodebuild, as an alternative to --workspace. | | --configuration Debug\|Release | Xcode build configuration. | | --sdk | iphonesimulator, iphoneos, watchsimulator, watchos, appletvos, or xros. tvOS and visionOS are device builds only. | | --xcode-version | Xcode for this one command. See Xcode and tool versions. | | --build-setting KEY=VALUE | Pass an xcodebuild build setting, replacing a Limrun default with the same key. Repeatable. | | --env KEY=VALUE | Set an environment variable for every remote build command. Repeatable. | | --log-processor xcbeautify\|none | Format xcodebuild output with xcbeautify (default), or stream it unchanged. | | --xcodegen-spec, --xcodegen-project, --xcodegen-project-root | Pin XcodeGen inputs. See Generated Xcode projects. | | --git-init | Run git init in the synced workspace before the build. | | --ignore | Skip matching paths during sync. Repeatable. | | --include | Force-sync matching paths that would otherwise be excluded. Repeatable. | | --additional-file local=remote | Sync an extra file, such as ~/.netrc, on every build. Repeatable. | | --basis-cache-dir | Directory for the local delta sync cache. | | --id | Build on this sandbox instead of the most recent one for your workspace. | | --ios | Build on a sandbox with an attached iOS simulator, creating and attaching one if needed. | | --inactivity-timeout | Create a fresh sandbox for this build and delete it shortly after its last activity. Cannot be combined with --id. | | --upload | Save the build artifact to Asset Storage under this name. | | --upload-ttl | Expiry of the uploaded asset as a Go duration. Defaults to 336h (14 days), counted from each upload. Requires --upload. | | --upload-option key=value | App metadata to record on the uploaded asset (displayName, bundleIdentifier, shortVersion, buildVersion, deeplink). Values read from the built bundle take precedence. Repeatable. | | --artifact-name | Filename of the built .app to upload, skipping artifact discovery. | | --signed-upload-url | Upload the artifact to your own presigned URL. Defaults to LIMSIGNEDUPLOAD_URL. | | --snapshot-key, --snapshot-restore-keys, --snapshot-paths | Save and restore the sandbox workspace as a disk snapshot. The older --cache-* names still work. | | --expo-app-dir | Path to the Expo app inside a monorepo or ambiguous React Native workspace. | | --expo-force-prebuild | Run expo prebuild on the server even when the workspace already has an ios/ directory. | | --dev-server-url | URL a Debug React Native or Expo build opens on the attached simulator after install. | | --certificate-p12, --certificate-password, --provisioning-profile | Sign with your own certificate and profiles. See Sign and distribute. | | --signing-method, --team-id, --entitlements | Use Apple cloud signing. Also requires --asc-key-id, --asc-issuer-id, and --asc-key. See Sign and distribute. | | --upload-to-appstore, --asc-key-id, --asc-issuer-id, --asc-key | Upload the signed IPA to App Store Connect. | | --asc-wait-timeout | Watch App Store Connect's processing verdict for up to this long (default 0, max 1800). | | --auto-build-number | Set the build number to one more than the highest in App Store Connect, or 1 for a new app. Requires --upload-to-appstore. | | --detach | Return once the build is accepted. See Build logs and webhooks. | | --webhook-url, --webhook-header, --webhook-label | POST the terminal build result to your HTTPS endpoint. See Build logs and webhooks. | Troubleshooting | Symptom or message | Cause | Fix | |---|---|---| | Upload fails with built artifact not found | The scheme name and the built product name differ and discovery missed the bundle. | Pass --artifact-name with the .app filename; see When the product name differs from the scheme. | | The sync fails on a symlink | A relative symlink points outside the synced folder. | Pass --ignore with a pattern for the link to skip it. | | The sync warns that a file is tracked and ignored | The file is tracked by git but also matched by .gitignore. | Remove the file from git or from the ignore rule so the two agree. | | A generated, gitignored file is missing on the sandbox | .gitignore excludes it, or its parent directory is pruned. | Force-sync it with --include, using a pattern that also matches the parent directory. | | A project tool refuses to run outside a Git repository | The sync excludes .git. | Pass --git-init. | | The build log warns about ${VAR} in project.yml | XcodeGen expands variables with the sandbox's environment. | Do not rely on client-only variables in the spec; hard-code the value or generate the project locally and force-sync it. | | Expected an Xcode instance (sandbox...), got ios... | An ios simulator ID was passed to a lim xcode command, a leftover of the embedded Xcode sandbox. | Pass the Xcode sandbox's sandbox ID; see Move off the embedded Xcode sandbox. | | lim xcode logs shows RUNNING | The build has not finished; a snapshot is not a result. | Follow it with lim xcode logs --follow; see Build logs and webhooks. | Next steps Pick the Xcode release and the Node, Ruby, CocoaPods, and other tool versions a build uses. Produce signed device IPAs and upload them to App Store Connect and TestFlight. Detach from a build, read its logs later, and receive the result on your endpoint. Build and sign on every pull request from a Linux runner. --- ## Test in-app purchases URL: https://docs.limrun.com/docs/ios/in-app-purchases Serve StoreKit products from a local configuration on a cloud simulator, so purchases complete without an Apple ID or sandbox tester. Test in-app purchases StoreKit can serve in-app purchase products from a local .storekit configuration file instead of App Store Connect. Limrun iOS instances expose this local test environment through three TypeScript SDK calls: register a config, generate one from your sandbox products, or clear it. The CLI has no StoreKit command, so CLI users and coding agents run these steps from a short Node script. Your app makes the same StoreKit calls it makes in production, and the simulator answers them from the config. Before you start - A Limrun API key in LIMAPIKEY. If you haven't used the CLI before, follow the Quickstart first. - An iOS app that uses StoreKit or StoreKit 2 for in-app purchases. - Either a .storekit configuration file (Xcode creates one through File → New → File → StoreKit Configuration File), or a bundle ID that already has in-app purchases registered in the App Store Connect sandbox. You choose between the two flows below. 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 .storekit file instead of contacting App Store Connect. Xcode uses the same mechanism when you run a scheme with a StoreKit configuration selected; Limrun exposes it remotely through setStoreKitConfig, discoverStoreKitConfig, and clearStoreKitConfig. This has two consequences: - The app must be an ad-hoc signed simulator build, so the simulator runs it without an Apple Developer account. Attaching the simulator to an Xcode sandbox is the easiest way to build and install one, but any install path works. - The config is keyed by bundle ID. Setting it for com.example.MyApp does not affect other apps on the same simulator, and clearing it returns only that bundle to real sandbox behavior. Create an Xcode sandbox and an attached simulator Create the two instances and attach the simulator to the sandbox, the same shape as any build-and-test flow: Two URLs matter for in-app purchase work: - The Xcode sandbox URL (xcodeInstance.status.apiUrl, authenticated with xcodeInstance.status.token). The Xcode client connects here to sync source and run xcodebuild. - The simulator URL (instance.status.apiUrl, authenticated with instance.status.token). The iOS device client connects here to call the StoreKit helpers and drive the UI. Build and install the app Sync your source to the Xcode sandbox and build it. The build must be ad-hoc signed so StoreKit's local test environment accepts the bundle. A simulator build without certificate parameters is ad-hoc signed by default, so there is nothing to set up. After a successful build, the app installs on the attached simulator. The Python and Go SDKs do not sync or build. From those languages, run lim xcode build as a subprocess. Build with Xcode covers workspaces, schemes, log streaming, and artifact upload. Connect a device client The three StoreKit helpers live on the iOS device client: setStoreKitConfig, clearStoreKitConfig, and discoverStoreKitConfig are in the TypeScript SDK only. The released Go SDK (v0.9.0) iOS client, github.com/limrun-inc/go-sdk/websocket/ios, covers taps, the element tree, and screenshots but not these helpers, and the Python SDK only manages instances. From Go or Python, run the StoreKit steps from a Node subprocess or call the underlying HTTP endpoints; the create, build, launch, and tap steps still work in your primary language. Pick a flow You can populate the local config in two ways: register a file you already have, or generate one from App Store Connect. The second option, discover, reuses work you have already done. When you set up an app for distribution, you register every in-app purchase in App Store Connect: 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 pre-production environment that real devices reach when signed in with a sandbox tester Apple ID. Discover skips the tester sign-in. While discoverStoreKitConfig waits, the app makes one sandbox product request (when you open the paywall), Limrun captures the response from the StoreKit cache on the device, and turns it into a .storekit file that matches what App Store Connect has for that bundle. | Flow | What you need | When to use it | |---|---|---| | Explicit (setStoreKitConfig) | A .storekit file on disk | You already maintain a config in Xcode, or you want the same products on every run. | | Discover (discoverStoreKitConfig) | In-app purchases registered in the App Store Connect sandbox for your bundle | You have a working App Store Connect setup but no .storekit file. | The flows combine well: discover is convenient for the first run, and most teams switch to an explicit file once they want reproducible tests. Register a .storekit file Read the file's bytes and register them for the bundle ID: setStoreKitConfig takes the raw file bytes (Buffer or Uint8Array) and applies the config to the bundle immediately. The app's next StoreKit fetch uses the local test environment instead of Apple's servers. If the app is already running, restart it so it picks up the config: Generate a config from sandbox products If your bundle ID already has in-app purchases registered in the App Store Connect sandbox, Limrun can read the cached sandbox response from storekitd and turn it into a config: The call blocks on the server until a sandbox response is cached or the timeout fires. While it waits, the app must fetch StoreKit products at least once, which usually means opening the paywall. Drive the app yourself through instance.status.signedStreamUrl, or automate it with the device client. After discovery succeeds, the generated config is registered for the bundle, exactly as if you had called setStoreKitConfig. No second call is needed. Run a purchase Both flows leave the simulator in the same state: products and prices come from the local config. Drive the paywall and complete a purchase like any other UI test: What you see on screen. The StoreKit purchase sheet looks like the production one, except the bottom shows [Environment: Xcode] instead of [Environment: Production] or [Environment: Sandbox]. There is no Apple ID prompt and no sandbox tester sign-in, and the confirmation completes without a network call to Apple. What your app sees. From the app's side the purchase is real. You await the purchase call, get back a signed transaction, and your listeners and entitlement checks run unchanged. The only difference from a device purchase is the signing certificate: it is Apple's local test certificate, not the production one. Server-side receipt validation fails. If your backend verifies receipts through Apple's production or sandbox verifyReceipt or the App Store Server API, those calls reject local-environment transactions because the signing certificate is not Apple's production one. Stub the validation server in your test environment, or skip it when the build is ad-hoc signed. Purchases persist across relaunches. The simulator remembers a purchase while the local config is active. softReset wipes the app's own data container, but the StoreKit test state lives outside it, so it is not a documented way to clear purchases. For a clean purchase state between test cases, create a fresh simulator, for example with --rm or a new label per test run. Clear or replace the config Clear the config for a bundle, or overwrite it with another file: clearStoreKitConfig is safe to call when nothing is registered, so you can put it in test teardown without checking state first. Troubleshooting There are two common causes. The bundle ID has no in-app purchases registered in the App Store Connect sandbox, so discover has nothing to read. Or the app never fetched products during the wait; open the paywall, or any screen that calls Product.products(for:) or its StoreKit 1 equivalent, while discover is running. A longer timeoutSeconds fixes neither cause. The app picked up the config before the build was ad-hoc signed, or the build was signed with a real developer team. Rebuild without certificate parameters so the server ad-hoc signs it, and reinstall. Confirm the bundle ID you passed to setStoreKitConfig matches the app's CFBundleIdentifier exactly. The .storekit file is out of sync with what the app requests. Open it in Xcode or a JSON editor and confirm every product ID the app passes to Product.products(for:) is present, with localized prices for the storefront the test runs in. StoreKit 2 delivers transactions through the same channel as in production, but it needs an active listener. Start a for await transaction in Transaction.updates loop early in the app's lifecycle, not lazily on the paywall. Next steps The full device-control surface: taps, element queries, screenshots, app lifecycle. Schemes, log streaming, and artifact upload behind xcodebuild(). Build every pull request and post a live preview link from GitHub Actions. --- ## Connect to local services URL: https://docs.limrun.com/docs/ios/local-services Route an iOS app's connections to a mock backend, dev server, or VPN-only API on your machine. Connect an iOS simulator to local services A destination tunnel sends the connections you declare from the simulator through the machine that runs the tunnel, so the app keeps using the addresses it already has, such as localhost:8000 or api.corp.example. This page covers iOS simulators; for Android emulators, see Connect an Android emulator to local services, which reuses the selector and inspection rules described here. This page covers traffic from the device to your service. Reaching a server that runs inside the device from outside, such as WebDriverAgent, is the opposite direction and uses targetHttpPortUrlPrefix. See Appium for how that works. Start a tunnel from an iOS simulator Declare each destination the app needs as a selector, start the tunnel, then query or stop it when you are done: The app keeps using the literal localhost:8000, and the tunnel connects it to localhost:8000 on the machine running the tunnel. CLI only: --detach runs the tunnel in the background. Without it, the tunnel runs until you stop the command. --verbose logs every forwarded connection and dial failure; with --detach those lines go to the tunnel log file that tunnel status points to. TypeScript only: the tunnel starts on a device client. The caller owns the returned tunnel; disconnecting the device client does not close it. The Python and Go SDKs do not start destination tunnels. Raw HTTP cannot start one either: it takes the CLI, the TypeScript SDK, or an implementation of the tunnel's multiplexed WebSocket protocol. Choose selectors A selector is either an exact TCP destination or a domain: - Exact host:port. localhost:8000 also captures the loopback aliases 127.0.0.1:8000, [::1]:8000, and [::ffff:127.0.0.1]:8000. Literal IP selectors work the same way for non-loopback addresses. Write IPv6 in brackets, as in [::1]:8000. - Domain. An exact name (api.corp.example) or a . wildcard (.staging.example). Domain selectors are intercepted on the instance and dialed from your machine whether or not the name resolves on public DNS, so your DNS, /etc/hosts, and VPN apply. Wildcards are label-bound: *.staging.example covers a.staging.example but not staging.example. localhost and IP literals are not domains; declare them as host:port. Apps that resolve DNS themselves over HTTPS (DoH) bypass domain interception. For those, declare the resolved IP instead. Limits and behavior - A tunnel carries TCP only. CIDRs and UDP are not supported. - One tunnel accepts up to ten exact host:port selectors and up to 64 domain selectors. - Ports 1 to 65535 are accepted except port 53. - One instance has one active destination tunnel, and its selector list cannot change. To add or remove a destination, stop the tunnel and start it again with the complete list. - A WebSocket loss closes the tunnel's connections. Start a new tunnel to recover. - An open idle tunnel does not count as instance activity and does not prevent the inactivity timeout. - A tunnel captures connections the app opens after the tunnel is ready. An app that reached a destination earlier keeps its open connections and can reuse them outside the tunnel, so start the tunnel before you launch the app or open the page, or relaunch the app once the tunnel is ready. - If the app connects to a selector while its local service is down, only that connection fails; the tunnel and its other selectors stay up. JSON status records the failure as lastDialFailure. Restart the service at the same destination and the existing tunnel carries later connections. Clients on @limrun/api 0.48 or older (lim before 0.31) speak an earlier tunnel protocol that current instances reject with invalid_message. Upgrade to use tunnels. Inspect tunnel traffic HTTP and HTTPS through a tunnel are inspected by default. Each completed request is printed as one summary line (in the tunnel log file when detached), and requests also stream live to the console's network panel. These flags control inspection on lim ios tunnel, and lim android tunnel takes the same flags: | Flag | What it does | |---|---| | --[no-]inspect | Print one HTTP summary per completed request. On by default; --no-inspect turns inspection off. | | --har | Write inspected traffic, with bodies, as HAR 1.2 at this path. | | --har-body-limit | Maximum captured bytes per request or response body. Defaults to 10485760. | | --persist | Upload a network log, including bodies, as a session artifact when the tunnel stops or the instance terminates. | | --ttl | Lifetime of the persisted network log. Defaults to 259200 (3 days), maximum 2592000 (30 days). Requires --persist. | This captures a HAR file and keeps a network log for seven days: HTTPS is decoded with a certificate authority the device trusts, so apps that pin certificates fail through inspected selectors. Leave the pinned host out of the selectors, or pass --no-inspect to keep TLS end to end. In TypeScript, pass inspection: { enabled: false } to startTunnel for the same effect. Connect Expo and Metro Metro can listen on its usual local port while Expo advertises localhost. Start the tunnel for Metro's port, then start Metro with EXPOPACKAGERPROXY_URL pointing at the same address: EXPOPACKAGERPROXYURL makes Expo use localhost and the declared port in the manifest, bundle URLs, and deep links. Set it inline so it takes precedence over project dotenv values. Keep this shell open for Metro, and copy the printed TUNNELURL into a second terminal for the launch command. If port 8081 is taken, pick another port and use the same value in the selector, TUNNEL_URL, and Expo's --port. Open your development build with that URL: For Expo Go, replace --dev-client with --go in the Metro command and open: Use the legacy fixed-port reverse tunnel lim ios reverse remains for existing workflows that already pick a simulator-facing port in the reserved range 57090 to 57099. New workflows should use the destination tunnel above. This exposes local port 57090 to the simulator: The CLI mapping is ` or :. --local-host defaults to 127.0.0.1; non-loopback hosts are meant for debugging. On start, the CLI prints the address the app must connect to. It is an internal listener IP near the simulator, not 127.0.0.1. The reverse tunnel has these constraints: - Client-first services only. The app in the simulator opens the connection. Server-first protocols, where the server speaks first, are not supported. - No automatic reconnect. The tunnel fails closed: if the WebSocket drops, the listener and its connections close. Restart the tunnel to recover. - Idle tunnels don't keep the instance alive. Opening a tunnel counts as activity once; after that, only traffic through it does. A quiet tunnel lets the inactivity timeout fire as usual, so agents that leave tunnels behind stop accruing instance time. - Vanished clients lose their port. If the tunnel client stops responding without closing (a suspended VM, a frozen process), the server closes the session and frees the remote port within about two minutes. A port held by a live tunnel stays reserved, and a second tunnel on it fails with already in use. - TypeScript SDK and CLI only. The Go and Python SDKs do not have it. - iOS only. On Android, use a destination tunnel or adb reverse over the ADB tunnel. Troubleshooting | Symptom or message | Cause | Fix | |---|---|---| | Tunnel start fails with invalid_message | The client is @limrun/api 0.48 or older, or lim before 0.31. | Upgrade the CLI or SDK. | | One connection fails while others work | The local service for that selector is down. JSON status shows it as lastDialFailure. | Restart the service at the same destination; the tunnel carries later connections. | | A domain selector has no effect | The app resolves DNS over HTTPS, or the wildcard does not cover the bare domain. | Declare the resolved IP as host:port, or add the exact domain next to the wildcard. | | You need another destination | The selector list of a running tunnel cannot change. | Stop the tunnel and start it again with the complete list. | | All tunnel connections dropped | The tunnel's WebSocket closed. | Start a new tunnel. | | lim ios reverse fails with already in use | A live reverse tunnel holds that port. | Pick another port from 57090 to 57099, or stop the other tunnel. | | The app ignores the tunnel | The app opened its connections before the tunnel was ready. | Start the tunnel first, then relaunch the app. | | An app with certificate pinning fails through the tunnel | Inspection decodes HTTPS with a certificate the pinning app rejects. | Leave that host out of the selectors, or pass --no-inspect. | Next steps Drive the app once it reaches your service: taps, typing, logs, recordings. The same destination tunnel for Android emulators. The opposite direction: reach WebDriverAgent inside the simulator through targetHttpPortUrlPrefix`. --- ## Run a simulator URL: https://docs.limrun.com/docs/ios/run-simulator Create a cloud iOS simulator, drive its screen and apps, and collect logs, recordings, and files. Run an iOS simulator Code examples have tabs for the lim CLI and the SDKs, and the site remembers the tab you pick. The loop is always the same: read the screen, act on what is there, and collect the evidence you need. To install your own build on the simulator, build it with remote Xcode or Bazel and attach the simulator to the sandbox. Create a simulator Create a simulator with one command: In a build loop, attach the simulator to your most recent Xcode sandbox instead. The attach installs the sandbox's latest successful build, and every later successful build reinstalls automatically: The output includes the instance ID and the signed stream URL. Open the signed stream URL in any browser to watch and control the simulator; the token is part of the URL, so nobody has to sign in. Agents should share it with the user at the start of a task. --reuse-if-exists returns an existing instance with exactly the same labels instead of creating a new one, so retries land on the same warm simulator. Scope reuse to a task with a label such as --label issue=; see Labels and reuse for how the lookup works. These lim ios create flags cover most workflows: | Flag | What it does | |---|---| | --attach | Attach the simulator to the most recent Xcode sandbox and install its latest successful build. Pass an Xcode sandbox ID as the argument to pick another one. | | --xcode | Also create an Xcode sandbox and attach the simulator to it. Use this when you want both instances before the first build. | | --reuse-if-exists | Return an existing instance with exactly the same labels. | | --label key=value | Attach a metadata label. Repeatable. Used by --reuse-if-exists, listing, and cleanup. | | --model iphone\|iphone-duo\|ipad\|watch | Device model. Defaults to iphone. | | --jurisdiction us\|eu\|as | Keep the instance inside one jurisdiction. Hard constraint: creation fails when no region there has capacity. | | --region | Deprecated. Only a preference that falls back to other regions when full. Use --jurisdiction. | | --inactivity-timeout | Delete after this much idle time, such as 1m, 10m, or 3h. Defaults to your organization setting. | | --hard-timeout | Delete after this much wall time. Defaults to no limit. | | --install | Upload a local app and install it after creation. Repeatable. --asset-ttl sets the expiry of the uploaded file. | | --install-asset | Install an app that is already in Asset Storage. Repeatable. | | --install-url | Install an app from a signed download URL. Repeatable. | | --keychain | Restore an encrypted keychain asset after creation. See Save and restore the keychain. | | --force-bundle-id | After this app first enters the foreground, bring it back whenever it is closed or backgrounded. It does not launch the app. | | --display-name | Name shown in listings and the console. | | --rm | Delete the instance when the CLI process exits. Useful for one-shot runs. | | --no-open | By default the CLI opens the signed stream URL in your local browser once the instance is ready. Pass --no-open on headless hosts and in CI. | | --record, --events, --app-logs | Start a persisted session recording, event log, or app log capture as soon as the instance is ready. --persist-ttl sets how long they are kept (default 72h). | Run lim ios create --help for the complete list. The console Playground offers the same device models: Configure the simulator from the SDK Pass the same settings as spec fields on the create call. The TypeScript example sets the model, jurisdiction, timeouts, a scheduling clue, and an app to install at boot; the comments in the Python and Go examples name the fields those SDKs do not have yet: For iOS app assets, launchMode decides whether the app opens after installation. Omit it to install only; set ForegroundIfRunning or RelaunchIfRunning to open the app during provisioning. The complete spec and status field tables, including the legacy sandbox.xcode.enabled field, are in Instance spec and status. Platform integrators should pass the end user's IP or location as a clue so the simulator runs near them; see client location clues. Connect a device client The SDKs open a WebSocket client with the instance's apiUrl and instance token. The CLI opens its own connection for every command, so it has no client step. status.token is returned only to a credential that can control the instance; a read-only API key or a viewer seat gets none, so check for it before connecting. The TypeScript SDK covers every method on this page. Go SDK v0.9.0 covers screenshots, the element tree, taps, element values, typing, key presses, app launch and install, URLs, orientation, lsof, and simctl; the SDK capability matrix has the full comparison. The Python SDK only manages instances, so drive devices from Python through the CLI. Both clients return once the WebSocket is connected. The TypeScript client also populates client.deviceInfo (UDID and screen size); the Go client in v0.9.0 has no device info accessor, so read the screen size from a screenshot. The Go snippets on the rest of this page reuse this ctx and client. Read the screen Read the screen before every action. The element tree lists each element's label, accessibility ID, type, and frame, and it is the source of truth for what the user can interact with. A screenshot is a visual check on top of it. Screenshots are JPEG images. Their dimensions are in points, the same coordinate space as tap(x, y). To tap a coordinate from a scaled screenshot in TypeScript, pass the screenshot's size so the server converts it: Tap an element Select elements by accessibility ID first, then by label, and use coordinates only as a last resort. Accessibility selectors survive layout changes; coordinates do not. tap-element accepts --ax-unique-id, --ax-label, --ax-label-contains, --type, --title, --title-contains, and --ax-value. Every field you pass must match (AND, not OR). The contains variants are case-insensitive. The TypeScript selector fields are AXUniqueId, AXLabel, AXLabelContains, type, title, titleContains, and AXValue. Go names the same fields AccessibilityID, Label, LabelContains, ElementType, Title, TitleContains, and Value. tapElement taps with a real synthesized touch, so the app reacts as it would to a finger. Elements the accessibility tree can see are scrolled into view before the tap. A selector that matches nothing fails in about a second, and iOS creates list rows lazily, so a row below the fold is often missing from the tree. For those rows, pass --scroll-search (SDK: { scrollSearch: true }): it pages the screen and retries until the row appears, up to three screens down and six screens up, within the call's time budget (timeoutMs in the SDK, 90 seconds by default). --activate ax (SDK: { activate: 'ax' }) performs an accessibility press instead: no scrolling and no touch events, but it works on elements without a usable frame. Adjust values Steppers, sliders, and text fields can be set directly instead of tapped. set-text writes a value through accessibility: the value lands verbatim, but the app's text delegates and keyboard behavior are skipped. TypeScript only: incrementElement and decrementElement increment and decrement a stepper or slider. Use set-text to seed field values. Use type, below, when the app must react as if a user typed. Type and press keys type sends real hardware key events into the focused field: text delegates fire, and the field's own keyboard behavior applies (a default text field capitalizes the first letter, for example). It fails when no field has focus, so tap the field first. If you focused a field another way, such as a coordinate tap, and the focus check misreads it, pass --no-require-focus to type anyway. press-key accepts shifted symbols (@, #, ?) and adds the shift itself. --modifier is repeatable and accepts shift, command, control, and alt. toggle-keyboard is the equivalent of pressing ⌘K in the simulator. Scroll and swipe scroll is a finger-swipe gesture. --amount is the distance in pixels (default 300), --coordinate is the start point (default: the screen center; set it when the scrollable view is elsewhere, such as in a modal), and --momentum from 0.0 to 1.0 controls the fling. Directions follow the device rotation, so down scrolls the content down in landscape too. For gestures with explicit start and end points, such as pull-to-refresh, carousel paging, or sliders, use swipe. Longer durations drag more precisely; shorter ones fling. The default is 300 ms. CLI only: the SDKs have no swipe call. In TypeScript, compose a swipe from touch actions in a batch. Rotate the device Switch between portrait and landscape from the SDK, or with a setOrientation action in a batch: Open URLs and deep links open-url opens web URLs in Safari and registered URL schemes in their apps: Run a batch of actions When a sequence should run without a round trip between steps, send it as one batch. The server runs the whole batch next to the simulator. It stops at the first failing action, and the call fails with that action's error. When every action succeeds, it returns one result per action. Agents use this for anything longer than a single tap. Longer CLI sequences read better from a YAML or JSON file: The CLI sizes the batch timeout from its waits and action count; override it with --timeout . In TypeScript, pass timeoutMs as shown above. Action type values fall into two groups: - High-level, one to one with the single-action commands: 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 for buttonDown and buttonUp are home, lock, side, applePay, and softwareKeyboard. A native iPhone Duo also supports volumeUp and volumeDown. Build custom gestures Pair touchDown, touchMove, and touchUp with wait actions to build long presses, flings with custom inertia, or anything scroll does not cover. This drag moves one finger up the screen: For a two-finger gesture, add x2 and y2 to every touchDown, touchMove, and touchUp. Each event moves both fingers together. Keep the same finger order throughout the gesture and lift both fingers with touchUp. Supplying only one of x2 or y2 rejects the batch before it runs. One or two fingers are supported; omit both fields for a single finger. Both fingers share one coordinate space. Without screenWidth and screenHeight, coordinates use the device's display size in its current orientation. To use coordinates from a resized image, pass that image's width and height on every touch action; the server scales and rotates both fingers. A two-finger tap from the CLI: This TypeScript pinch-out spreads two fingers apart over 320 milliseconds: The example assumes portrait orientation. The local TwoFingerAction type adds x2 and y2 in case your installed SDK's type definitions lack them; the runtime accepts them either way. x and y are the first finger; x2 and y2 are the second. lim ios perform --file actions.json accepts the same actions array as JSON, so you can save a gesture and replay it from the CLI. To zoom out, start with the fingers apart and move them toward each other before lifting both. Manage apps List, launch, terminate, and install apps by bundle ID. list-apps includes system apps. launch-app streams the app's logs and exits when the app exits. CLI only: pass --detach to return right after the launch, for example in scripts. install-app accepts a local archive, which the CLI uploads to Asset Storage first, or a remote URL, which the instance downloads itself. --md5 (SDK: md5) enables server-side install caching for remote URLs. If you don't know the bundle ID, check the Xcode project or run list-apps after a successful build. TypeScript only: use onExit when your runner needs to react to a crash or termination. The callback receives the stdout and stderr lines recorded for that launch. Reset app data between runs Between test cases, wipe the app's data container and relaunch it instead of reinstalling. The TypeScript SDK does this with softReset: Read app logs Tail recent lines (default 100) or stream live output for one app: In TypeScript, the stream emits one line event per log line, batched about every 500 ms on the server, plus error and close. For low-level debugging beyond one app's stdout and stderr, stream the simulator's syslog until you interrupt it: Record a video For anything that moves (animations, gestures, games), record a video instead of taking screenshots, and attach it to the pull request. Recording runs on the server. --quality accepts integers from 5 (default) to 10; higher values increase fidelity and file size. TypeScript only: stopRecording can save to a local file and hand the bytes to a presigned URL in the same call. Keep captures after the session Captures can also be persisted to Limrun storage, so they outlive the CLI process and the instance. Start them when you create the simulator, or persist a recording when you start it: --record keeps recording until you stop it or the instance terminates. --events captures a log of taps, scrolls, and commands, and --app-logs launches the app and captures its output. Persisted captures are kept for 72h unless --persist-ttl says otherwise. List them afterwards: Each events and app-logs entry is a timestamped JSONL file. Simulate the camera Play a video file as the simulator's camera for QR-code scanning, document capture, video-call screens, or any AVCaptureSession flow that needs predictable frames. The file uploads to the instance and replaces the live browser camera until you clear it; apps see it through their regular capture pipeline. Without looping, the feed freezes on the last frame after one pass, so the app keeps receiving frames instead of seeing the camera stall. Anything AVFoundation can decode works, such as H.264 or HEVC in .mp4 or .mov. Portrait recordings play upright. Simulate the microphone Play a local audio file (WAV, MP3, M4A, or AAC) as microphone input. Playback loops until you stop it; --once plays a single pass: Set the clipboard Put text on the simulator clipboard and read it back. Apps paste it like text copied inside the simulator, so the edit menu's Paste shows no permission prompt. For example, to paste a one-time code into a login form: Read and write user defaults Read, write, or delete the simulator's user defaults with its defaults tool. Pass the arguments after --. Apps read defaults at launch, so relaunch the app under test after a change. For example, to switch the device language and region without UI automation: lim ios defaults accepts only read, write, and delete. Post notifications Post a Darwin notification, or set and read a notification's state, inside the simulator with its notifyutil tool: For example, the simulator drives Face ID through these notifications. Enroll once, then answer each Face ID prompt with a match or a non-match. Touch ID devices use fingerTouch in place of pearl: Transfer files List a directory to find the exact relative path, then pull, push, or delete files with the same container flags: Without --bundle-id, file commands target the simulator staging folder. With --bundle-id, the default container type is app; use data for writable app data such as Documents, Library, and tmp, or pass an App Group identifier. Every path that ls returns is relative to the selected root and works with the other commands unchanged. Save and restore the keychain If your app keeps login or session state in the iOS keychain, save the keychain after signing in once and restore it into later simulators. Save it from the signed-in simulator: The command stores an encrypted Keychain asset, generates an encryption key, and prints the matching restore command. Store the key somewhere safe: restoring needs it to decrypt the archive. You can also restore the keychain while creating a simulator: To generate the key yourself and keep it off the command line, pass it on stdin: Fold an iPhone Duo The iPhone Duo model must be available to your organization. Create one, then read or change the hinge: The hinge accepts fractional angles from 0° (closed) to 180° (flat). Omit the angle to read the fold state. Use fold 0 to close and fold 180 to unfold; there is no separate unfold command. --orientation accepts portrait, pud (portrait upside down), landscape-left, or landscape-right, and changes independently of the hinge, so lim ios fold --orientation portrait --id rotates without moving the hinge. This drives the native simulator hinge, so apps receive Apple's hinge and layout updates. --display outer targets the cover display and --display inner the unfolded one, for screenshot and tap. setDuoOrientation accepts portrait, landscape-left, landscape-right, and pud. Display screenshots are upright, report dimensions in points, and contain JPEG data, including when saved with lim ios screenshot --display; tapDisplay uses those coordinates. These calls do not activate a display, so fold or unfold first; a display that is not ready reports an error. Use the display-specific methods for Duo automation. The element tree, the default screenshot, and recordings do not follow the inner display automatically. In the browser stream, the Duo starts in a 2D frame and offers a lazy-loaded 3D frame. Both modes provide hinge and rotation controls, touch input on the cover and inner display, and single-finger touch and drag, but not multi-touch. Click or hold the frame's Sleep/Wake and volume buttons to control iOS. In 3D, the position starts unlocked; Lock position stops camera rotation while the screen and buttons stay active. Rotating the view moves the camera, while Rotate device changes the native orientation. Laptop view sets the hinge and orientation; it does not enable Apple's separate Table Mode. To embed the Duo frame in your own app, see iPhone Duo frame. Use macOS dev tools When the commands above don't cover what you need, the instance exposes a small set of macOS developer tools: simctl streams stdout and stderr. xcrun is limited to the --sdk and --show-* flags, and xcodebuild to -version. Reuse and clean up Each simulator is single-tenant: only one client should drive it at a time. Scope instances per user, pull request, or session with labels, and let reuseIfExists return the same warm instance on the next call. For platform integrators, this matters when a UI action triggers create: without reuse, repeated clicks can create hundreds of instances, each billed; with it, they settle on one. This create call returns the same instance for the same session across multiple agent runs: Delete an instance when you are done, or list instances by label and delete every match: CLI only: without an ID, lim ios delete removes the last iOS instance the CLI created in this workspace, so list by label first if you created several. lim ios list shows ready instances by default; pass --all or --state for other states. The console's Instances tab shows the same data. Filter by state or label selector, sort by duration or creation time, and reopen any ready instance with one click: After a delete, or after the inactivity timeout fires, the row leaves the Ready filter. The All filter still shows it as terminated until it ages out: Troubleshooting | Symptom or message | Cause | Fix | |---|---|---| | tap-element fails after about a second with a selector that matched nothing | The element is not in the accessibility tree yet. iOS creates list rows lazily, so rows below the fold are often missing. | Pass --scroll-search, or put scroll actions before the tapElement in a batch. Read lim ios element-tree to confirm the selector values. | | type fails because no field is focused | type requires a focused text field. | Tap the field first. If you focused it with a coordinate tap and the focus check misreads it, pass --no-require-focus. | | Typed text is capitalized or autocorrected | type sends real key events, so the field's keyboard behavior applies. | Use set-text to write the exact value through accessibility. | | A two-finger batch is rejected before it runs | A touch action has only one of x2 or y2. | Supply both on every touchDown, touchMove, and touchUp, or neither. | | An iPhone Duo display call reports an error | The display is not active in the current fold state. | Fold or unfold first with lim ios fold 0 or lim ios fold 180. | | lim ios delete removed the wrong instance | Without an ID it deletes the last iOS instance the CLI created in this workspace. | Run lim ios list and pass the instance ID. | Next steps Produce the build that installs onto the simulator. Let the app on the simulator reach a server on your machine or VPN. Run unit and UI test targets on the simulator with lim xcode test. Serve StoreKit products from a local config and complete purchases without an Apple ID. Render the simulator in your web app with ``. --- ## Sign and distribute URL: https://docs.limrun.com/docs/ios/sign-and-distribute Produce App Store ready IPAs from any machine and get them to testers without a local Mac. Sign and distribute iOS builds A device build on an Xcode sandbox can come out signed and ready to ship. Sign with your own certificate and provisioning profiles, or let Apple's cloud signing manage them, then upload the IPA to Asset Storage or straight to App Store Connect for TestFlight. Every flag here adds to a normal Xcode build. Device builds Device builds (iphoneos, watchos, appletvos, xros) compile every target for its standard architectures and turn code coverage instrumentation off, matching what Xcode's archive action produces. An embedded watch app keeps the arm64_32 slice App Store Connect requires, and no binary carries coverage sections, so the IPA passes App Store validation. Projects that pin an unsupported architecture such as armv7 in ARCHS fail as they do in Xcode. Simulator builds compile only the host architecture. tvOS and visionOS Pass the device SDK and the scheme explicitly for tvOS and visionOS: These builds use the same unsigned IPA, manual signing, cloud signing, and upload paths as iOS device builds. tvOS and visionOS simulator builds, simulator attachment, and XCTest runs are not supported yet. Sign with your certificate To produce a signed IPA for distribution, select a device SDK and pass a P12 certificate, its password, and a provisioning profile. Add an upload so the IPA lands in Asset Storage with a download URL. If you omit the SDK, signing defaults to iphoneos; pass watchos, appletvos, or xros explicitly for another platform. TypeScript takes the certificate and profiles base64-encoded. Limrun verifies the signed app with Apple's code-signing verifier before uploading it. A signature that Apple's tooling would reject fails the build with a clear error instead of producing a broken IPA, so a signed build that succeeds has already passed Apple's verification. Apps with extensions or a watch app App Store signing needs a distinct provisioning profile for every bundle the app embeds: the app itself plus each app extension (a WidgetKit widget, a share sheet, an intents extension) and watch app, all issued for the same distribution certificate. Repeat --provisioning-profile with one profile per bundle, or pass several entries in provisioningProfilesBase64. Each profile is matched to its bundle by the application-identifier it carries, so order does not matter, but every profile must name an explicit, non-wildcard bundle ID. Before signing, Limrun checks that every embedded bundle in the built app has a matching profile. A missing one fails the build with the uncovered bundle ID, so you see it immediately instead of as an App Store validation rejection after upload. Include the certificate chain Export your p12 with its certificate chain included, so signing works regardless of which Apple WWDR intermediate issued your certificate. Keychain Access exports include the chain. With OpenSSL, pass the chain with -certfile: Download the signed IPA When you configure an upload, the build result includes a signed download URL. Anyone with the URL can download the IPA without a Limrun API key, but the signature expires after 15 minutes, so use it right away and fetch a fresh one later with lim asset list --name --download-url. For links reviewers open days later, use PR previews. Cloud signing Cloud signing produces a signed IPA without a p12 or provisioning profile. Xcode authenticates with an App Store Connect team API key, and Apple creates or reuses a cloud-managed certificate and the required profiles during export. TypeScript passes these options as cloudSigning. Choose the method for the IPA you need: | Method | Use | |---|---| | debugging | Development-signed IPA for devices registered to the team. | | release-testing | Distribution-signed IPA for registered test devices. | | app-store-connect | Distribution-signed IPA for App Store Connect. | Cloud signing works for device SDK builds: --sdk iphoneos, watchos, appletvos, and xros. It requires a team API key, its issuer ID, and a --team-id that matches the key's Apple Developer team. For release-testing and app-store-connect, the key must be an Admin key or have Access to cloud-managed distribution certificates enabled in App Store Connect. Preserve entitlements Cloud signing archives your app unsigned, and Apple's export carries entitlements into the signed IPA only from an existing code signature. Capability entitlements from your project's .entitlements file (HealthKit, CloudKit, app groups, push) therefore do not survive on their own. Pass them with --entitlements, and the build server embeds them into the archive so the export keeps them. A bare path applies to the app. Use = for embedded bundles such as widgets or a watch app, repeating the flag per bundle. In TypeScript, entitlements is keyed by bundle ID, and the empty key targets the app. Three rules apply: - Every capability must be enabled on your App ID. The export registers toggleable capabilities such as HealthKit and push on the App ID for you. Capabilities that need extra configuration, such as app group assignments or special-agreement entitlements, must be set up in the developer portal first, or the export fails naming them. - Values must be fully expanded. Build-setting references such as $(AppIdentifierPrefix) are rejected; write the concrete team prefix instead. - Omit the keys the export manages. Leave out application-identifier, com.apple.developer.team-identifier, get-task-allow, and beta-reports-active; the export sets them from the generated profile and distribution method. Check what landed in the IPA: Troubleshoot signing | Build output | Cause | Fix | |---|---|---| | Unknown issuer hash | The certificate's issuing CA is not recognized and the p12 carries no CA chain. | Re-export the p12 with the certificate chain included (see Include the certificate chain). | | code signature verification failed | The produced signature failed Apple's verifier. | Retry the build; contact support if it persists. | | MAC verification failed or other password errors | The certificate password does not match the p12. | Check the value you pass as the certificate password. | | signing preflight failed: no provisioning profile covers ... | An embedded bundle (extension, watch app) has no matching provisioning profile. | Pass one --provisioning-profile per bundle ID named in the error. | | Cloud signing permission error | The API key cannot use cloud-managed distribution certificates. | Use an Admin key or enable Access to cloud-managed distribution certificates for the key. | | No Account for Team | --team-id does not match the API key's team. | Pass the Apple Developer team ID associated with the API key. | | Failed Registering Bundle Identifier | The bundle ID cannot be registered to this team, usually because another team owns it. | Use a bundle ID the team already owns, or one that is available to register. | Set up App Store Connect once Before the first upload, configure three App Store Connect settings. They make TestFlight delivery hands-off: 1. Create an App Store Connect team API key. In App Store Connect, go to Users and Access, open the Integrations tab, select App Store Connect API, and generate a Team Key. A Developer key is enough for upload with manual signing. Cloud distribution signing requires an Admin key, or Access to cloud-managed distribution certificates enabled for the key. One team key serves all your apps. Collect the three values the build flags need: - --asc-key-id: the Key ID shown next to the new key. - --asc-issuer-id: the Issuer ID shown at the top of the Integrations page. It belongs to the team, not the key. - --asc-key: the .p8 file from the key's Download link. Download it right away and store it like a password: Apple keeps no copy, and the link disappears once you leave the page. 2. Answer the encryption question at build time, or every build stalls in App Store Connect with "Missing Compliance" until someone answers manually. If your app only uses exempt encryption (HTTPS and the like), declare it in your project: If your app uses non-exempt encryption, answer the compliance questions in App Store Connect instead. 3. Enable automatic distribution on an internal beta group. In your app's TestFlight tab, create an internal group and choose automatic distribution, the option that gives the group access to all builds. Members then receive every new build with no per-build assignment. The setting is create-only, so if your existing group lacks it, create a new group with it enabled. With these in place, one build command puts the app on your testers' devices as soon as Apple finishes processing. Upload to App Store Connect --upload-to-appstore sends the signed IPA to App Store Connect after the build, where it becomes available for TestFlight and App Store distribution. It works with manual or cloud signing. A cloud-signed build can use the same API key for signing and upload with the app-store-connect method: In TypeScript, the upload options go in appstore, next to the signing options. With manual signing, keep the --certificate-p12, --certificate-password, and --provisioning-profile flags and add --upload-to-appstore with the --asc-* flags. App Store Connect upload supports iphoneos, appletvos, and xros; replace the SDK and scheme to deliver a tvOS or visionOS build. A standalone watchos build cannot use --upload-to-appstore; the CLI rejects it with --upload-to-appstore requires --sdk iphoneos, --sdk appletvos, or --sdk xros. An embedded watch app ships inside the iOS IPA. Combine with --upload if you also want the IPA in Asset Storage. Build with a released Xcode: Apple rejects uploads built with a beta. Xcode and tool versions explains how a bare-major pin guarantees that. Wait for Apple's verdict The build log streams the upload progress. By default the build succeeds as soon as the upload commits. Apple's processing routinely takes many minutes, so the verdict is left to App Store Connect, and the build prints the upload ID so you can check it there. To watch for the verdict instead, pass --asc-wait-timeout (waitTimeoutSeconds in the SDK's appstore options, up to 1800). A rejection within that window, such as a reused build number or a missing entitlement, fails the build with Apple's own error text. Expiry without a verdict is still a success, with the build processing on Apple's side. Number builds automatically Every upload needs a CFBundleVersion higher than the last one for that version. Pass --auto-build-number (autoIncrementBuildNumber: true in the SDK's appstore options) to set it to one more than the highest build number already in App Store Connect, or 1 for a new app. It requires --upload-to-appstore. The lookup runs server-side with the same API key used for the upload. Manual signing requires Xcode-standard versioning (CFBundleVersion = $(CURRENTPROJECTVERSION)), which modern Xcode templates and Expo prebuilds use. Cloud signing updates the unsigned archive before export. How the API key is handled Your API key travels with the build request over TLS. For cloud signing, the build service writes it with owner-only permissions in the disposable sandbox directory for the export command, then deletes it. The sandbox directory is discarded with the instance. Troubleshoot App Store Connect TestFlight delivery requires a signed iphoneos, appletvos, or xros build, manual or cloud signing, and an existing app record in App Store Connect for your bundle ID. Apple's API cannot create app records. Each upload needs a CFBundleVersion higher than the last one for that version. | Build output | Cause | Fix | |---|---|---| | bundle version must be higher (or similar Apple text) | The CFBundleVersion was already used by an earlier upload. | Rebuild with --auto-build-number, or bump the build number manually. | | HTTP 401 from App Store Connect | The key ID, issuer ID, and .p8 do not belong together, or the key was revoked. | Re-check the three values in App Store Connect. Team keys need the issuer ID; individual keys must omit it. | | HTTP 403 from App Store Connect | The key's role cannot upload builds. | Use a key with the Developer role or higher. | | no App Store Connect app with bundle id ... | No app record exists for the bundle ID you built. | Create the app record in App Store Connect first (Apps, then the plus button). | | Build stuck in "Missing Compliance" | The binary does not answer the export-compliance question. | Set ITSAppUsesNonExemptEncryption in Info.plist as described above. | Next steps Build a signed IPA on every pull request from a Linux runner. Add App Store and Google Play publishing to your own platform. Detach from long release builds and receive the result on your endpoint. Where uploaded IPAs live and how to fetch fresh download URLs. --- ## Disk snapshots URL: https://docs.limrun.com/docs/ios/snapshots Reuse build state across Xcode instances with disk snapshots and branch fallbacks. Disk snapshots Use a disk snapshot to carry source files, dependencies, and Xcode's DerivedData into your next instance. Snapshots belong to your organization. Without snapshot configuration, a new instance starts without a saved workspace. Save and reuse a disk snapshot With the CLI or TypeScript SDK installed and LIMAPIKEY set, create an instance with a snapshot key, build, and delete the instance from your project directory. Replace MyApp with your scheme: TypeScript only: the settings go in spec.snapshot, and the instance must finish its restore before you sync. followSnapshot(id) waits for it, as the example does. The first run builds from scratch. After a successful build, deleting the instance saves its workspace under myapp-main. Run the same sequence again to restore that snapshot before building. Each successful publication replaces the previous archive under that key. --wait-snapshot waits for publication and prints its result. Without it, deletion returns while publication continues in the background. Wait for publication before starting a build that needs the new snapshot. In CI, put the delete command in a cleanup step that runs even if the build fails. TypeScript only: deletion returns before publication finishes. To track progress, use getSnapshot(id) for the current state or followSnapshot(id, { side: 'save' }) for updates. Start the save watcher and wait for its onOpen callback before deleting, so it can observe publication before the instance disappears. A save phase of published confirms success; skipped means nothing was saved. Leave --snapshot-paths unset to save the whole workspace. This keeps source files and their sync state alongside DerivedData so Xcode can reuse unchanged build outputs. --basis-cache-dir is a separate, local cache for source uploads; it does not preserve remote build state. You can keep building on the same running instance without restoring again. The saved snapshot is for future instances and is only published when this instance terminates. Restore from another branch Use --snapshot-key for the destination and --snapshot-restore-keys for an ordered list of sources. For example, restore a pull request's previous snapshot, falling back to main, then save under the pull request's key: For each restore key, Limrun tries an exact match, then the most recently published snapshot whose key starts with that prefix. It moves to the next restore key only if neither matches. Prefixes are literal; no * is needed. With no match, the instance starts cold and can save a new snapshot after building. When --snapshot-restore-keys is omitted, --snapshot-key is also the restore key. When you supply a restore list, include the destination key yourself if you want to try it first. To consume a snapshot without replacing it, create an instance with only restore keys: Build on that instance as usual and delete it when finished. It will not publish a snapshot unless you explicitly bind a destination key later. Use keys that identify the project and Xcode version, such as myapp-xcode27-main. Keep fallbacks within the same Xcode version. For concurrent CI jobs, use separate destination keys to avoid overwriting each other's results. Configure disk snapshots before the build Set snapshot options on lim xcode create for predictable behavior. Restore keys and snapshot paths are fixed when the instance is created. --reuse-if-exists keeps the reused instance's original configuration. lim xcode build --snapshot-key ... also works when the command creates a new instance. On an existing instance, it only binds a save destination to a workspace already prepared for snapshots. It does not restore a snapshot or enable snapshots on an instance created without snapshots. Once a destination key is set, it cannot be changed for that instance. Existing --cache-* flags, --wait-cache, spec.cache, and cache-named SDK methods remain supported for compatibility. Use the snapshot names in new integrations. Understand a cold build or skipped save - Keep the synced project folder's name the same between runs. A different folder name puts the source at a different remote path, so Xcode cannot reuse the previous build state. - Changing Xcode versions clears incompatible cached build state. Use a separate snapshot key for each version. - A restore must use the same snapshot path set as the saved archive. Use a new key if you change that set. - A failed compile or cancelled build does not produce a new snapshot. A test build can still save its snapshot if compilation succeeds but tests fail. Syncing changes after a successful build prevents saving until another build succeeds. Running commands with lim xcode run alone does not qualify a workspace for publication. - A concurrent instance using the same restored workspace on the same host can cause a cold fallback with saving disabled. The CLI reports the restore outcome; check the --wait-snapshot output for the save outcome. --- ## Xcode and tool versions URL: https://docs.limrun.com/docs/ios/xcode-and-tools Control which Xcode release and which Node, Ruby, and CocoaPods versions your sandbox builds use. Xcode and developer tool versions By default, an Xcode sandbox builds with its node's default Xcode and a set of preinstalled developer tools. Xcode is selected per workspace, other tools through mise. To carry the workspace itself across sandboxes, use disk snapshots. Choose the Xcode version A sandbox builds with its node's default Xcode unless you select another installed version. The fleet carries one released (GA) Xcode per major and, while Apple is seeding one, one beta. Two selectors cover them: - A bare major (27) binds the newest GA release of that major on the node, never a beta. It follows Apple's point releases on its own. Use it for App Store builds. - A major.minor (27.1) pins that exact version. This is how you pick a beta. The CLI stores the selection as a workspace preference that later commands and new sandboxes use, while the TypeScript SDK's setXcode binds one running sandbox and stores no preference. The preference works the way a version manager remembers a version. A workspace is your git repository or worktree, or a directory assigned with lim set-workspace-dir. List the installed versions Print the versions the sandbox can build with: CLI only: each row shows the bound marker (* for the one in use), the Select value to type, the channel (ga or beta), and the full version. --quiet prints only the Select values, one per line. For example, on a node with three installed versions the Select values are 26, 27, and 27.1, and the last row reads 27.1 beta (27A9269). The versions on your node may differ. To see which Xcode the sandbox has selected: The CLI also prints the workspace preference when it differs from the selected Xcode. Pin a version for the workspace Set the preference with lim xcode use or lim xcode version set. Both remember the selector and switch the existing sandbox; with no sandbox, they save the preference for the next one. lim xcode build, test, rbe, and new sandboxes all use it. In TypeScript, setXcode switches the sandbox the client points at: lim xcode use xcode@27 is equivalent to lim xcode version set 27 and does not sync or write mise configuration. You can combine it with tool requests, such as lim xcode use xcode@27 node@24; only the other tools go into mise. --cwd applies only to mise tools, and --workspace chooses the Limrun workspace whose preference is set. Override the version for one command Pass --xcode-version to lim xcode build, create, test, or rbe. It overrides the preference for that command and leaves the preference unchanged: Forget the preference Remove the preference, and the sandbox goes back to the node default: How switching works The sandbox has one Xcode selected at a time, and the workspace preference wins. When a build finds the sandbox on another Xcode, because a colleague switched it or a fresh sandbox came up on the node default, it says so and switches before building. Every build prints the Xcode it runs with, such as Building with Xcode 27.1 (27A9269). - Switching Xcode invalidates build state produced with the other version, so the next build starts cold. For disk snapshots, use a separate key per Xcode lane, such as myapp-27 and myapp-27.1: archives are stored per key, and a restore under a different Xcode is wiped. - The switch is refused while a build, command, sync, or lim xcode rbe stack is running. Stop them first (lim xcode rbe --stop). lim xcode use xcode@ keeps the preference in that case, and the next command retries the switch. - Asking for a version the node does not have fails with the available list, and lim xcode use xcode@ records no preference for it. - A major.minor pin lasts until the fleet retires that version. Then lim xcode build fails with the daemon's message plus a hint to run lim xcode version set 27 or lim xcode version unset. - When a beta becomes GA, the release replaces the beta under the same major.minor selector, with one cold build. The bare major follows the newest released Xcode of its major, so it moves to 27.1 as soon as 27.1 is GA on the node. - Simulators keep running the fleet's default runtime. lim xcode test with a non-default Xcode warns and proceeds: XCTest bundles built by the selected Xcode run against the frameworks of the simulator's default runtime, which works for most suites but is not guaranteed. - Apple rejects App Store uploads built with a beta Xcode. Keep --upload-to-appstore on a bare-major pin (27, not 27.1), which only ever binds a released Xcode. In the SDK, gate the publish on channel === 'ga', not on betaSeed: Apple's 27.1 seed ships without a seed number. Reuse the workspace across sandboxes A sandbox's workspace, including dependency installs and build products, is lost when the instance terminates. A disk snapshot saves it under a key and restores it into a later sandbox, so the next build starts warm. Create the sandbox with --snapshot-key, and delete it with --wait-snapshot after a successful build. Disk snapshots covers keys, branch fallbacks, and the SDK. Use a separate key per Xcode version, because a restore under a different Xcode is wiped. Select developer tools Select tool versions in the sandbox after your project has synced, then install them and check the result: The three commands do different jobs: - lim xcode use selects tools in an existing sandbox after a project sync. Mise installs a requested version if needed. Explicit sandbox selections take precedence over project requests and apply to later commands. - lim xcode tools install syncs the project and runs mise install in the sandbox, installing the selections from synced project files. --no-sync installs the current selections without syncing. It never creates or replaces an instance. - lim xcode tools inspects an existing sandbox without syncing or creating an instance. --sync uploads local changes first. All three take --cwd apps/mobile for a nested project and --id to choose a sandbox, and all three need an existing sandbox. lim xcode use xcode@ is the exception: without a sandbox it saves the Xcode preference for the next one. Each lim xcode run and lim xcode build resolves the mise environment (mise env --json) once at the start. Dependency installation, project generation, builds, and their child processes all use that environment. Project requests and compatibility lines Limrun imports [tools] declarations from synced project mise files. Project requests take precedence over the detected package-manager major and Limrun's image defaults, and deeper project files override parent files within the synced directory. Your personal mise configuration on the client is not read or forwarded. Limrun guarantees compatibility lines, so image updates can deliver newer releases within a line: | Tool request | Sandbox compatibility line | |---|---| | node = "24.5.0" | 24 | | pnpm = "10.12.1" | 10 | | ruby = "3.3.7" | 3.3 | | python = "3.13.6", go = "1.24.4" | 3.13, 1.24 | | flutter = "3.44.6", dart = "3.12.2" | 3.44, 3.12 | | mint = "0.18.0" | 0.18 | Ruby, Python, Go, Flutter, Dart, and tools below version 1 keep major.minor; other numeric versions keep their major. Client patch pins and mise.lock do not freeze the image's release. latest keeps its mise meaning and opts out of a fixed line. For any other version string, run mise in the sandbox directly; see Pin an exact version. Preinstalled tools The image provides these tools: | Tools | Default line | |---|---| | Node.js, npm, npx | Node 22 and 24, default 22; npm and npx ship with Node | | pnpm | 10; 9 and 11 are also preinstalled | | Yarn | 1; 4 is also preinstalled | | Bun, bunx | 1 | | Ruby, RubyGems | 3.3 | | Bundler | 4 | | CocoaPods | 1, including the cocoapods-patch plugin | | CMake, ctest, cpack | 3 | | Java | JetBrains Runtime 21; Corretto 21 is available for Gradle vendor detection | | Flutter and its Dart SDK | Flutter 3.44 | | Mint | 0.18 | | XcodeGen | 2 | | xcbeautify | 3 | | zsign | 1, with Limrun's signing fixes | Xcode, Apple SDKs, and simulator runtimes are managed separately from mise. Select Xcode with lim xcode use xcode@27 as described above. Mise itself and Limrun's runtime helpers are part of the image. First installation The first sandbox operation that reads a project mise file runs mise install before exporting its environment. This happens once per sandbox, even when later files or tool selections change. Without a project mise file, commands keep using the image defaults. A failed or cancelled first installation stops that operation. Retry explicitly with lim xcode tools install, which also installs later tool selections. Select another Java vendor with a request such as java@corretto-17; upstream support for macOS and the selected runtime still applies. The managed CocoaPods resolver uses the selected CocoaPods tool; a project Gemfile does not select its gems. Environment the build sees NODEBINARY points to the Node that mise selected, in both runs and builds. For React Native and Expo builds, Limrun rewrites ios/.xcode.env.local with that binary and the selected tool PATH, so Xcode build phases use the same tools as dependency installation. Do not copy a client Node path into this file. The selected Java and Flutter installations also set JAVAHOME and FLUTTER_ROOT. Pin an exact version For an exact version or an explicit update, call mise inside the sandbox: mise install adds a version without changing the selection. mise use installs and records an explicit sandbox override for later operations in .limrun-runtime-mise.toml; it takes precedence over project tool requests. lim xcode use node@24 replaces that override with a Node 24 selection. A running operation keeps its environment snapshot. The next run or build picks up a version change. Use mise exec when a child command in the same operation needs the new version immediately. Tools and disk snapshots Tools preinstalled in the image are never part of a disk snapshot. Tools you install with mise live under .limbuild-sandbox/home/.mise/ and are saved when your --snapshot-paths cover that directory (the whole workspace by default); mise then selects among compatible image installations and restored user installations. The publication rule above still applies: a successful lim xcode build is required. Dependency installs rerun when the resolved tool versions change, and switching Xcode still invalidates restored build state. Mise gem wrappers contain absolute paths. After a snapshot restore moves the sandbox home, reinstall an affected user gem explicitly, for example lim xcode run -- mise install --force bundler. Image gem wrappers are packaged to support relocation. Next steps Sync, build, and install with the versions you selected. Ship a release build from a GA Xcode to App Store Connect. --- ## Asset Storage URL: https://docs.limrun.com/docs/platform/asset-storage Store your app builds once and install them on any simulator or emulator, at boot or into a running session. Asset Storage Asset Storage is Limrun's managed store for app binaries. A boot install means the device starts with the app ready, and a live install swaps in a new build without restarting the session. Build commands upload to it, preview links read from it, and platforms use it to hold their users' apps. What an asset is An asset is one stored file. Installable files are iOS simulator builds (.app, .app.zip, or .app.tar.gz) and Android APKs (.apk). Build uploads can also store signed device artifacts, such as an IPA from Sign and distribute or an AAB from Sign and publish, and lim ios keychain save stores encrypted keychain archives. Its record has this shape: | Field | Meaning | |---|---| | id | Stable, auto-generated identifier. | | name | Your identifier. Names are unique across your organization; to separate tenants, use prefixes. | | kind | App (default) for installable binaries, Keychain for keychain archives, such as those created by lim ios keychain save. | | displayName | Optional human-readable label shown in the console; falls back to name. | | md5 | Set once the bytes are uploaded. getOrUpload compares it to skip duplicate uploads, and installApp uses it for server-side caching. | | platform | Limits which platform can install the asset. Useful when one logical name, such as acme/my-app-v1.2.3, covers both an iOS simulator build and an Android APK. Responses also include os, a deprecated alias with the same value. | | expiresAt | When set, the time after which the asset, bytes and record, is deleted. See Set an expiry. | | signedDownloadUrl / signedUploadUrl | Time-limited URLs. Returned when you ask for them on list or get, and always on getOrCreate. | Upload an asset Pick the upload path by who holds the file: - getOrUpload (CLI lim asset push) when your code or CI has the file locally. It skips the upload when the bytes are already stored. - getOrCreate when something else does the upload, such as a worker on another host, a streaming source, or an end user's browser. Upload a local file getOrUpload computes the local file's MD5, checks Asset Storage for an entry with the same name and MD5, and skips the transfer when they match. It ships in the TypeScript and Go SDKs; from Python, use the two-step upload. Upload a local build under a name of your choice: When the MD5 matches an existing upload, the call returns the existing asset's URLs and transfers no bytes. Limrun's iOS preview GitHub Action gives each pull request its own asset slot with a naming convention like this one, so the slot can be deleted when the PR closes: The action itself builds and uploads in one step through the TypeScript SDK, using the same naming convention. lim xcode build --upload "preview/…" also builds and uploads in one step; see Build with Xcode. Upload from another process or a browser getOrCreate (GetOrNew in Go, getorcreate in Python) returns the asset record with a signedUploadUrl; you PUT the bytes to it yourself: For browser-direct uploads, call getOrCreate on your backend, where the API key lives, and hand only the signedUploadUrl to the browser. The browser PUTs to it without touching your LIMAPIKEY or proxying bytes through your servers, which fits platforms whose end users upload their own builds. Set an expiry Every upload path accepts an optional time-to-live as a Go duration string: --ttl on lim asset push, ttl on getOrUpload and getOrCreate in the TypeScript SDK, --upload-ttl on lim xcode build and lim gradle build, and --asset-ttl on create --install and install-app. The asset, bytes and record, is deleted that long after the upload. The minimum is 1m; 1d is not valid Go syntax, so write 24h. The default depends on how the asset was uploaded: - Direct uploads (lim asset push, getOrUpload, getOrCreate) never expire unless you pass a TTL. Re-uploading under the same name keeps the recorded expiry; pass a TTL on the re-upload to change it. - Build uploads (lim xcode build --upload, lim gradle build --upload, and Bazel uploads) default to 14 days. Each build upload without an explicit --upload-ttl moves the expiry to 14 days from that upload, so actively rebuilt assets stay alive and abandoned ones get swept. Install an asset There are two moments to install: at instance boot, or into an instance that is already running. Both accept an Asset Storage entry (by name or ID) or a publicly reachable HTTPS URL, such as a GitHub release asset or an S3 presigned URL. URLs on your LAN do not work, because the instance downloads the file. Pre-install at boot Add entries to spec.initialAssets in the create call, and the device starts with the app on its home screen. On the CLI, --install uploads a local file to Asset Storage first, and --install-asset references a stored asset; repeat either flag for several apps. Create a simulator with the app installed. The SDK examples show every source: An iOS initialAssets entry has these fields: | Field | Type | Meaning | |---|---|---| | kind | 'App' \| 'Keychain' | App installs an app. Keychain restores an encrypted keychain snapshot and requires encryptionKey; see Save and restore the keychain. 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' | ForegroundIfRunning brings the app to the foreground, launching it if needed; RelaunchIfRunning kills and relaunches it. Omit it to install without launching. | The Python SDK takes the same fields in snakecase (initialassets, assetname, launchmode), and the Go SDK as IosInstanceNewParamsSpecInitialAsset values. Both accept only kind: 'App'. Create an emulator with the app installed. The SDK examples show one entry per source: An Android initialAssets entry has these fields: | Field | Type | Meaning | |---|---|---| | kind | 'App' \| 'Configuration' | App installs an APK; Configuration carries an instance-level setting. | | source | 'URL' \| 'URLs' \| 'AssetName' \| 'AssetNames' \| 'AssetIDs' | URL and AssetName install one APK. The plural variants take a list and install it as one split-APK group, base APK first, config splits after. There is no singular asset-ID source on Android. | | url / assetName | string | Singular identifier. | | urls / assetNames / assetIds | string[] | Split-APK group. The first entry is the base APK; the rest are density, locale, or ABI splits. | | configuration | object | Used when kind: 'Configuration'. | Android initialAssets has no launchMode field: each app installed at boot is launched right after installation, so the instance is ready to use on first connect. An app that ships as a base APK plus config.*.apk splits must install as one atomic group, with a plural source; the server installs it with pm install-multiple. The CLI cannot group files, so use an SDK. Pre-install APKs has the split-APK examples in every language. kind: 'Configuration' entries are settings, not stored files: the only one today is ChromeFlag, which Playwright needs. A configuration entry has no MD5, no signed URLs, and no assets.list representation, and the CLI has no flag for it. See Set Chrome flags. For the rest of the create call, such as regions, clues, timeouts, and sandboxes, see Instance spec and status. It also shows a full create response. Install on a running instance When a user rebuilds during a live session, push the new build and tell the running instance to install it. No restart and no reconnect are needed: CLI only: install-app accepts a local path, which it uploads to Asset Storage first with MD5 deduplication, or a URL it passes to the instance. installApp accepts md5, timeoutMs (default 120_000), and launchMode. The Go iOS client has InstallApp too; sendAsset is TypeScript-only, and the Python SDK has no device client. From those languages, use lim ios install-app and lim android install-app, or adb install over the tunnel. Split-APK groups install only at boot. For opening device clients and the full app lifecycle, see Manage apps and Pre-install APKs. Use Expo Go and other catalog apps Limrun maintains a small catalog of prebuilt apps under the appstore/ name prefix. It holds Expo Go, which lets React Native users open a Snack or a local Expo project on a remote device without uploading anything, and the Maestro XCTest runner used by the Maestro integration. | 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) | | appstore/maestro-ios-runner-2.5.1.tar.gz | iOS | Maestro XCTest runner for Maestro flows | Install a catalog app at boot the same way as your own assets. The appstore/ prefix is part of the name: Pair the boot install with the openUrl prop of ` to land the device on the user's Expo project on first paint: That is the whole integration: the backend creates the instance with the Expo Go asset, and the frontend renders with the project URL. No Expo Go upload and no build pipeline. List the catalog to see what else is available. The server fills the result with your own assets first and gives catalog entries only the remaining slots, so filter on the appstore/ prefix to see the whole catalog: The API strips the appstore/ prefix before it queries the catalog. nameFilter: 'appstore/Expo-Go-54.0.6.tar.gz' with includeAppStore: true works as expected, but a partial prefix such as namePrefixFilter: 'appstor' never matches a catalog entry, because the comparison happens after the prefix is removed. Organize assets for multiple tenants Asset names are global to your organization. The store has no tenant scoping, no labels, and no folders; the one primitive is the name itself, queried with namePrefixFilter in the TypeScript SDK or --name-prefix on the CLI. Put the tenant, or any stable scope such as a workspace, customer, project, repository, or PR number, at the front of the name: The slash is not required, but it reads well and survives any prefix scan. Pick one separator and use it across your platform. Two common operations follow from the prefix: The platform field splits a tenant's namespace further when one logical name covers both an iOS simulator build and an Android APK: Integrations written before the field was named platform may still read os, which is returned as a deprecated alias with the same value. List, get, and delete assets assets.list, assets.get, and assets.delete are the calls you wire into an admin UI, a cleanup job, or an offboarding flow. List assets with a name filter, fetch one by ID, and delete it: assets.list accepts these filters: | Filter | Behavior | |---|---| | nameFilter | Case-sensitive exact match on name. Cannot be combined with namePrefixFilter. | | namePrefixFilter | TypeScript SDK, CLI (--name-prefix), and REST only. Case-sensitive prefix match. LIKE wildcards (%, _) are treated as literal characters. An empty string is rejected with 400, so omit the parameter when you do not want filtering. Cannot be combined with nameFilter. | | includeDownloadUrl / includeUploadUrl | Set to true to include signed URLs on each returned asset. Default false, for a smaller response. | | includeAppStore | Include catalog assets, returned with the appstore/ name prefix. | | limit | Maximum number of items returned. Default 50. assets.list returns a single batch and does not page. | The CLI maps these to --name, --name-prefix, --download-url, --upload-url, and --include-app-store, and adds --kind App|Keychain to filter by kind. For example, list one tenant's assets: To download an asset's file, use lim asset pull . Next steps Render on an instance that boots straight into the customer's app. lim xcode build --upload` lands an iOS simulator build in Asset Storage. Upload a build on every pull request and post a preview link. Asset calls and parameters across TypeScript, Python, and Go. --- ## Embed a device URL: https://docs.limrun.com/docs/platform/embed-simulator Show your users a live iOS simulator or Android emulator inside your own web app. Embed a live device in your web app Your backend creates the instance with the Limrun SDK and hands the browser only that instance's URL and token. On the page, ` from @limrun/ui streams it into a , forwards touch and keyboard input, and exposes imperative control through a React ref. Platform integrators use this path: products whose end users need a live mobile device on the page, such as mobile preview tools, internal QA dashboards, and cloud coding agents that work on iOS or Android. . The same look-and-feel ships in your app." /> The Limrun console's Playground uses this same component for its streaming view, so what you ship to your users looks the same. Before you start - An API key, kept server-side. Create one at console.limrun.com under Settings → API Keys. See Authentication. - A backend that can create instances. The single contract between your backend and ` is the pair status.endpointWebSocketUrl and a token. The examples pass status.token. status.streamToken opens only the streaming WebSocket, so a browser can hold it while status.token stays on your backend. Every spec field, clue, and timeout is listed in Instance spec and status. - A React 18+ frontend, such as Vite, Next.js, Create React App, or Remix. If your platform also builds your customers' apps, Build with Xcode covers compiling iOS apps on a remote Mac. How the pieces fit The browser talks to the instance directly. Your backend is the only party that holds the API key: - End user's browser. Renders and sees only a per-instance WebSocket URL and an instance token. It never sees your API key. - Your backend. Holds the API key, creates the instance with the Limrun SDK, returns { endpointWebSocketUrl, token } to the browser, and handles teardown. - Limrun control plane. Provisions the instance and returns a token scoped to it. The instance token works only while that one instance is running and grants access to nothing else. See Concepts for how API keys and instance tokens differ. Create the instance on your backend 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 FastAPI; only the framework wrapper changes. Four choices in that call shape the user experience: - Scheduling clues. Pass the end user's coordinates as a REST API ClientLocation clue, or their IP as a ClientIP clue, so the instance lands in a region near them. ClientLocation wins when both are present. See client location clues. - Labels for tenant scope and reuse. With reuseIfExists: true, Limrun returns an existing instance whose labels match exactly, so a user who reopens their tab lands on the same warm instance. See Labels and reuse for how the lookup works. - Pre-installed app. Reference an uploaded build, or an App Store catalog entry such as Expo Go, in spec.initialAssets and the device opens with the app installed. See Asset Storage. - Session lifecycle. inactivityTimeout defaults to your organization's setting, which may be shorter than an embedded session needs; set 10 to 30 minutes explicitly. hardTimeout is a forced-termination cap, default '0' (no cap). Always call delete when the tab closes. Install @limrun/ui Add the UI package to your frontend. It ships ES modules and TypeScript types, and its styles are bundled into the build: 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, and post a stop when the user leaves: keepalive: true on the cleanup request lets it complete while the tab closes, so the instance terminates promptly instead of waiting for inactivityTimeout. Component props These props match @limrun/ui 0.16.0, the current latest release on npm. The iPhone Duo props ship in the 0.17.0 release candidate (npm install @limrun/ui@rc). | Prop | Type | Default | Use | |---|---|---|---| | url | string | required | WebSocket URL from the instance. Pass instance.status.endpointWebSocketUrl. | | token | string | required | Instance token. 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 several browser tabs resolve to the same reused instance. Create it once per mount, for example with useState and crypto.randomUUID() as in the example above; a value that changes between renders restarts the stream. | | openUrl | string | none | A URL or deep link to open on the device once the component is ready, such as https://example.com, myapp://orders/42, or exp://… for Expo Go. | | showFrame | boolean | true | Draw the device frame. See iPad and Apple Watch for models without built-in frames. | | assets | object | bundled frames | Device frame and boot-logo images. The default entry bundles Limrun's frames; the @limrun/ui/lite entry omits them so a frameless embed stays small. | | autoReconnect | boolean | false | Reconnect automatically when a working session's connection drops, instead of showing the Retry button. | | onConnectionStateChange | callback | none | Reports the coarse connection state, connecting, connected, reconnecting, failed, or terminated, for status UI outside the component. | | onTerminated | callback | none | Fires once when the component concludes the instance is gone, not just disconnected. After a drop it probes the instance over HTTP; a 404 stops reconnecting, replaces the Retry button with a terminated message, and calls this. | | className | string | none | Extra class names applied on top of the component's defaults. | | inspectMode | boolean \| 'hover-only' | off | Draw accessibility boxes over the stream. true makes boxes selectable, with Tap, Copy selector, and Copy id actions, and blocks device input; 'hover-only' previews boxes under the cursor while input still passes through. | | onAxSnapshotChange | callback | none | Receives each new accessibility snapshot while inspect mode is on, for your own side panel or agent prompt. Identical snapshots are not re-sent. | | onInspectSelectionChange | callback | none | Receives the element the user clicks in inspect mode, with the snapshot at that moment, or null on deselection. | | onAxStatusChange | callback | none | Reports the accessibility subsystem status: idle, starting, ready, unavailable, or error, with an error message for the last two. Recovery is automatic. | | axPollIntervalMs | number | 500 | Base interval between accessibility-tree fetches in inspect mode. Unchanged snapshots back off; input triggers a short burst of more frequent fetches. | | axMaxBackoffMs | number | 2000 | Upper bound on the fetch interval while snapshots stay unchanged. | | androidElementTreeOptions | object | none | Android element-tree fetch options, currently waitForIdleTimeoutMs. | | onCameraDemandChange | callback | none | Reports when an app opens or closes its camera, including the browser's actual capture settings after permission or a track change. See Camera. | | onCameraStats | callback | none | About once per second while the camera is streaming, reports outbound WebRTC stats such as codec, fps, bitrate, round-trip time, and quality limitation; null once it goes idle. | | microphoneEnabled | boolean | false | Capture the user's microphone and stream it into the device's microphone. Apps that consume microphone input also start capture on their own. | | onMicrophoneStateChange | callback | none | Reports microphone capture state: whether it is active, the device label, and any error. | | deviceModel | 'iphone-duo' | auto-detected | 0.17.0 release candidate. Enable the Duo viewer explicitly; otherwise the component discovers Duo support from the connection. It does not select the model when creating an instance. | | duoModelUrl | string | built-in model | 0.17.0 release candidate. URL of a prepared custom Duo GLB. See iPhone Duo frame. | Built-in input handling The component translates browser events into device input without any wiring on your side: | Browser input | Device input | |---|---| | Mouse click or tap | Tap | | Mouse drag or single-finger touch drag | Swipe | | Alt + drag | Two-finger pinch, mirrored around the screen center | | Two-finger touch on a touchscreen | Two-finger pinch or pan | | Letters, numbers, arrows, Enter, Backspace, modifiers | Device key events | | Cmd + V / Ctrl + V | Reads the browser clipboard and pastes into the focused field on the device | | Cmd + M / Ctrl + M | Menu key | Keyboard events forward while the embedded screen has focus; tap the screen to focus it. These gestures apply to the standard viewer. iPhone Duo supports single-finger taps and drags in both modes, but not multi-touch or mouse-wheel scrolling, and in Duo's 3D view Alt-drag rotates the view instead of pinching. Camera When an app opens its front or back virtual camera, asks the browser for the matching user or environment camera and sends that track to the instance. Switching cameras in the app replaces the WebRTC track automatically, so your embed needs no camera toggle of its own. Facing is a preference, not a requirement. A facing change restarts browser capture with an ideal constraint, so on a laptop with one camera the browser returns that camera again while the app still sees the selected virtual position and its normal front-camera mirroring. The browser and WebRTC choose the capture resolution, and the virtual-camera output follows each decoded frame's rotated dimensions, including changes after a camera switch. Observe camera demand with the callback prop: iOS instances always report camera demand. Android instances report it when the instance supports camera injection (Android 15 and later); older Android instances stay silent. Camera capture requires HTTPS or http://localhost, as getUserMedia does. To feed a recorded video instead of a live camera, see Simulate the camera with a video. iOS extended touch area iOS treats the home-indicator strip as part of its gesture surface. adds a 60-pixel touch area below the visible screen, so a drag that starts there and moves up enters the iOS gesture system the way it would on hardware: swipe up to go home, open the app switcher, or pull from the bottom. This area applies to the standard viewer; on iPhone Duo, start the gesture on the visible display. Reconnects runs its own retry budget: - During the initial connect it makes up to 3 attempts with a 1 s delay between them. If the control channel does not open within 15 s, that attempt counts as failed. - If all 3 initial attempts fail, the component shows a Retry button and stops. Clicking it, or calling ctrlRef.current.reconnect(), starts over. - If a working connection drops later, the component shows the Retry button with autoReconnect={false} (the default), or reconnects in the background with autoReconnect={true}. Use autoReconnect 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 keep-alive ping fires every 10 s while the page is visible and pauses while the tab is hidden, so an idle background tab does not keep the instance alive past its inactivityTimeout. Control the device from React Pass a ref to call methods imperatively. The handle is populated once the WebSocket and control channel are open: | Method | Returns | Notes | |---|---|---| | openUrl(url: string) | void | Sends a URL or deep-link message over the WebSocket. The component URL-decodes the string before sending. | | screenshot() | Promise<{ dataUri: string }> | Rejects after 30 s without a reply. In the 0.17.0 release candidate it takes an optional 'outer' \| 'inner' display for iPhone Duo. | | terminateApp(bundleId: string) | Promise | Rejects after 30 s without a reply. iOS only today. | | sendKeyEvent({ type, code, shiftKey?, altKey?, ctrlKey?, metaKey? }) | void | type is 'keydown' \| 'keyup'; code is a DOM KeyboardEvent.code value such as 'KeyA', 'Enter', or 'ArrowDown'. | | reconnect() | void | Tears down the current WebSocket and RTC connection and starts a fresh attempt with the same url and token. | | refreshAxTree() | Promise | Fetches the accessibility tree now, outside the normal poll cadence. | | getAxSnapshot() | AxSnapshot \| null | The most recent snapshot, or null when none has arrived or inspect mode is off. | | setInspectHighlight(element) / setInspectSelection(element) | void | Drive the overlay's highlight or selection from your own UI; pass null to clear. | | getAxStatus() | AxStatus | The current accessibility status, the same value onAxStatusChange reports. | The inspect helpers do nothing when inspect mode is off or the WebSocket is closed. Each method in use: These are browser-side controls. For richer control from your server, such as taps by accessibility selector, element trees, video recording, or app resets, drive the same instance with the device clients in Run a simulator or Run an emulator. Both paths work on one instance at the same time. iPad and Apple Watch showFrame ships device art for iPhone (iOS), Pixel 9 (Android phone), and Pixel Tablet (Android tablet). iPad and Apple Watch have no dedicated frame today, so render them frameless: The video stream keeps the correct aspect ratio and resolution, so you can wrap the component in your own chrome. Select the model on the backend with spec.model: 'ipad' or spec.model: 'watch'. End-to-end example This self-contained backend and frontend pair creates an iPhone with Expo Go pre-installed and a client-IP clue, renders it, and tears it down on unmount. Drop the two files into a fresh project for a working embedded simulator: A fuller version of this pattern, with a UI for switching platforms and iOS models, lives in typescript-sdk/examples/fullstack. iPhone Duo frame The Duo viewer ships in the @limrun/ui 0.17.0 release candidate (npm install @limrun/ui@rc). Create an iPhone Duo on your backend by setting spec.model to iphone-duo in lim.iosInstances.create. The model must be available to your organization. Return instance.status.endpointWebSocketUrl and instance.status.token to the frontend as in the provisioning example, then render the Duo with the same component: deviceModel can be omitted when the connection reports native Duo support. The 2D frame and the procedural 3D model ship with @limrun/ui, so you do not host a model file. The CLI and device API control the simulator; the UI package renders its frame. The component defaults to a lightweight 2D view with a silver Duo frame, hinge spine, and physical buttons. Both views share the fold, hinge-angle, and rotation toolbar. Select 3D to load the folding frame on demand; switching back to 2D releases the 3D renderer while the simulator stays connected. - 2D view. Shows the active display flat, with a brief hinge animation when you fold or unfold. The animation loads on demand and respects reduced motion; without WebGL, the view stays flat. When a screen image is available, the animation holds it briefly while it waits for a frame from the incoming display. - 3D view. Shows the physical hinge pose, and position starts unlocked. Drag the frame or background, or Alt-drag the screen, to rotate the view. Ordinary screen gestures control iOS. Use Lock position to prevent rotation. - Hardware buttons. In both views, the Sleep/Wake and volume buttons support clicking and holding. Hover near an edge to reveal its icons, then click either the icon or the physical button. The icons follow the device when it rotates. Camera Control is not supported yet. Set showFrame={false} to show only the active display, without the frame, hardware buttons, viewer toolbar, or 2D/3D toggle. Touch, drag, and keyboard input stay active, and the video follows native display and orientation changes without loading a 3D renderer. Use the device client or CLI to control the hinge and orientation outside the viewer. For a custom 3D body, pass duoModelUrl with a prepared GLB that you are licensed to serve. Limrun does not bundle a third-party model. The file must follow the Duo appearance contract. The live screens and fold controls keep working with a custom appearance. The prop replaces only the 3D body; the 2D frame and its fold animation keep the built-in appearance. Next steps Upload customer builds and reference them in spec.initialAssets` so the device opens on their app. Every create field, clue, and status URL your backend can use. Server-side device control: selectors, element trees, recording, app resets. Android create options, the ADB tunnel, and OS-version clues. --- ## Publish to the stores URL: https://docs.limrun.com/docs/platform/publish-to-stores Let your users ship their apps to the App Store and Google Play without leaving your product. Add App Store and Google Play publishing to your platform Your users connect their Apple and Google developer accounts once. From then on, a publish button in your product has your backend sign, build, upload, and track each release on Limrun. What a connected account unlocks Connecting the developer account once gives your platform the signing material and store authorization this guide uses to: - Sign release builds. Let Apple keep a cloud-managed iOS certificate, or store a distribution certificate and profile in your own secret store. Sign Play-ready AABs with the app's Android upload key. - Publish new builds. Upload iOS builds to App Store Connect and TestFlight, and publish Android builds to Google Play's internal track. The same connection can also back device installs over WebUSB or a QR code, store reporting, and listing management through the store APIs; this guide does not implement those. The runnable reference is typescript-sdk/examples/publish-to-stores. It includes a React frontend, an Express backend, both store flows, and an authenticated build-completion webhook. What your platform needs Before implementing either store flow, prepare: - A Limrun API key in your backend's LIMAPIKEY environment variable. - The lim CLI installed on the backend worker that starts builds. - Alternatively, use the TypeScript SDK clients directly to sync and trigger builds. - App source available to that worker at a local project path. - A durable secret store for API keys, Android upload keystores, and any manually managed Apple certificates and provisioning profiles. - A public HTTPS webhook endpoint for build completion. - You can also follow the logs live, or poll after starting the build. - A browser frontend for the Apple and Google account connection flows. The example uses JSON files for secrets and an in-memory map for publish status. Both are demo implementations; replace them with your secret manager and database. 1. Build the shared backend Both stores use the same backend responsibilities. Create the Limrun client only in trusted server code. The frontend never needs the organization API key. For Apple connection, mint a short-lived token scoped to the Apple relay and return that token to the browser: See the example's POST /session route. @limrun/apple-auth and @limrun/play-auth both accept the same SigningSecretStore shape: Back this interface with your existing database, KMS, or secret manager. Keep the names stable because later publish requests use them to resolve credentials: - Apple distribution certificate: /DISTRIBUTION - App Store Connect API key: /APPSTORECONNECTAPIKEY - Apple provisioning profiles: metadata includes the team ID and bundle ID - Android upload keystore: /UPLOAD The example's SigningSecretStore adapter and file-backed implementation show the complete request and response shapes. A detached build finishes after the initial CLI process exits. Give each publish a random token and pass it as a custom webhook header: Store the token with the publish record. When Limrun calls the webhook, compare X-Publish-Token, persist the payload, and mark the publish as succeeded or failed. The callback URL must be publicly reachable over HTTPS; do not expose your secret-store or token-minting routes with it. Build logs and webhooks documents the payload and delivery rules. The example runs the webhook receiver on a separate port and performs constant-time token matching. 2. Add App Store connection The user needs an Apple Developer Program account with permission to create App Store Connect API keys. Let them choose who maintains the signing certificate: - Apple cloud signing uses only the App Store Connect API key. Apple creates, retains, and reuses the cloud-managed distribution certificate and provisioning profile. - Manual signing creates and stores the distribution certificate and App Store provisioning profile in your secret store. Your application is responsible for renewing and replacing them. Both modes prepare an App Store Connect app record and API key for the upload. In the browser, initialize useAppleIDLogin with the scoped token returned by your backend: If result.requiresTwoFactor is true, collect the verification code and call appleLogin.submitTwoFactorCode(code). After authentication, call finalize() and list the account's teams with listAppleTeams. The example implements this state machine in useConnect.ts. List the selected team's explicit bundle IDs with listAppleBundleIDs. Let the user select an existing ID or register one with createAppleBundleID. Ask for the app's display name at the same time. It is required when you create the App Store Connect app record. Run these actions after the user confirms the team, bundle ID, and signing mode: 1. Switch to the team's App Store Connect provider when the team has a provider ID. 2. Call ensureAppStoreConnectApp for the bundle ID and app name. 3. Call ensureAppStoreConnectApiKeySecret and save the result. 4. For manual signing, call ensureAppleCertificateSecret, create or refresh an App Store provisioning profile that references that certificate, and store the profile. Cloud signing requires the App Store Connect team key to be an Admin key or have Access to cloud-managed distribution certificates enabled. Limrun uses the key for cloud signing and upload; manual mode uses it for upload only. Reuse valid stored resources when the user reconnects. Sign and distribute explains both signing modes in detail. 3. Publish the iOS app When the user clicks publish, send the project path, team ID, bundle ID, signing mode, optional scheme, and webhook URL to your backend. The backend then: 1. Loads the App Store Connect API key from the secret store. 2. For manual mode, also loads the distribution certificate and App Store provisioning profile. 3. Writes the selected credentials to a temporary directory with owner-only permissions. 4. Starts a release build for the target Apple device SDK with either cloud or manual signing flags. 5. Requests automatic build-number selection, upload to App Store Connect, a completion webhook, and detached JSON output. 6. Deletes the temporary files after the CLI exits. The cloud-signing invocation has this shape: Manual mode replaces --signing-method and --team-id with: The upload still uses the --asc-* flags. Add --scheme when the project needs one. For tvOS or visionOS, use --sdk appletvos or --sdk xros with the matching scheme and App Store Connect app record. Cloud signing requires a team API key and its issuer ID. Spawn the CLI with an argument array instead of constructing a shell command, and redact the certificate password from logs. Both signing modes include the app's symbols in the IPA, so App Store Connect symbolicates crash reports for the build. This needs dSYMs from the build, which the Release configuration produces by default (DEBUGINFORMATIONFORMAT=dwarf-with-dsym). The complete implementation is in startPublish. A successful upload appears in App Store Connect and TestFlight. The user still attaches the processed build to an App Store version and submits that version for review in App Store Connect. 4. Add Google Play connection Google requires the Play Console app listing to exist before an API client can publish to it. Ask the user to create the listing with the exact Android package name and grant their Google account release permission. Create a Google OAuth Web application client whose authorized JavaScript origins include your frontend origin. Pass its client ID to requestGoogleAccessToken: Keep the access token in browser memory. Send it to your backend only with a publish request, and ask the user to sign in again after it expires. Read expo.android.package from Expo projects or applicationId from app/build.gradle and app/build.gradle.kts. Let the user correct the detected value. Use the Google token to verify that the listing exists and that the account can access it. If the listing is missing, link the user to Play Console and retry after they create it. The example contains both package detection and the browser connection state. First check your secret store for /UPLOAD. - For an app that already has an upload key, import that keystore and its alias and passwords. - For a new app, call generateAndroidUploadKeystore(packageName) and store the returned values. - Never replace an existing upload keystore without an explicit key-reset process. Future uploads signed by a different key will be rejected. The stored data contains keystoreBase64, keystorePassword, keyAlias, and keyPassword. The example's usePlay hook checks again immediately before storing so concurrent requests cannot overwrite a key. 5. Publish the Android app Send the project path, package name, Google access token, and webhook URL to your backend. The reference flow publishes to the internal track. The backend then: 1. Loads /UPLOAD from the secret store. 2. Writes the keystore to a temporary owner-only file. 3. Passes keystore and key passwords through environment variables. 4. Starts a release AAB build with automatic versionCode selection. 5. Publishes the AAB to Google Play's internal track. 6. Deletes the temporary file after the CLI exits. The invocation has this shape: The reference UI supports only the internal track. Its backend accepts another track value, but it does not expose the release-status handling required for a complete production-track flow. Add that handling before offering other tracks in your product. The full internal-track implementation is in startAndroidPublish. 6. Return progress to the frontend The initial publish endpoint should return as soon as the detached build is accepted: Store at least publishId, state, start time, webhook token, and the Limrun Console URL returned by the CLI's detached JSON output. Your frontend can poll a status endpoint or subscribe to your own event stream while the build runs. When the authenticated webhook arrives: 1. Match it to the publish using the webhook token. 2. Store the payload and its receipt time. 3. Treat status: "SUCCEEDED" as success and other terminal statuses as failure. 4. Surface consoleUrl and logsUrl when present. Use the persisted log for failure diagnostics. The example's usePublish and usePlay poll every three seconds. Its backend map is process-local, so restarting the example loses active publish records. Persist these records in your platform. Production checklist - Keep LIMAPIKEY and signing secrets out of the browser. - Give the browser only a short-lived Apple relay token. - Keep Google access tokens in memory and avoid logging them. - Encrypt stored certificates, private keys, profiles, keystores, and passwords. - Do not expose secret-store endpoints to the public internet. - Authenticate and correlate every completion webhook. - Persist publish state before starting the detached build. - Delete materialized credential files after the CLI exits. - Preserve existing Android upload keys and Apple credentials across publishes. - Show users the Limrun Console and persisted log links for failed builds. Run the reference implementation The example requires Node.js and Yarn 1. Clone the TypeScript SDK repository, then install and start both halves: In another terminal: Run a public HTTPS tunnel to the webhook receiver on port 3001, paste that URL into the UI, and open http://localhost:5173. The example README covers its local ports, webhook behavior, secret-directory sharing, and both store tabs. Next steps Manual and cloud signing, entitlements, and App Store Connect uploads in depth. Upload keys, release AABs, and Google Play version codes. The webhook payload, delivery rules, and persisted build logs. --- ## Quickstart URL: https://docs.limrun.com/docs/quickstart Install the CLI, build a sample iOS app on a cloud Mac, and drive it on a simulator from your terminal. Quickstart This takes about three minutes. You need Node.js 20 LTS or newer; you do not need a Mac. Setting this up for a coding agent instead? Follow Set up a coding agent. It installs the Limrun skills, which walk the agent through the same loop. Try it in the browser first The console has a Playground that creates and streams an instance without installing anything. Pick an operating system, optionally an app to preinstall, and click Start. Build and run an app Install the lim package globally with the package manager you use: On a machine with a browser, log in: The CLI opens the console in your browser, completes authentication, and stores the API key in ~/.lim/config.yaml. On a headless host such as a CI runner, a devcontainer, or a cloud agent, create a key instead: open console.limrun.com/settings, go to API Keys, click Create Token, and export it: Clone the sample app, or cd into an iOS project you already have: Run the build from the project directory: The first run creates an Xcode sandbox, syncs the project to it, and runs xcodebuild there. Build logs stream back as they happen. For a project with several schemes, add --scheme and either --workspace or --project . Create a simulator attached to the sandbox: Attaching installs and launches the latest build right away, and every later successful build reinstalls it. The CLI prints the signed stream URL and opens it in your browser. The URL carries its own token, so anyone you share it with can watch and control the simulator without signing in. Drive the app from your shell The sample's first screen has a name field and a Say hello button. Read the screen, type a name, and check the result with lim ios commands. Without --id, each command targets the instance you created last in this git repository: The heading changes to Hello, Ada!. The element tree lists every label, accessibility identifier, type, and frame on screen, which is where nameField comes from. Read the tree before each action and select elements by identifier; selectors keep working when the layout changes, pixel coordinates do not. type sends real key presses at the cursor, so it adds to whatever the field already holds, and it fails when no field has focus. To replace a field's whole value instead, use set-text, then tap the button: The app also logs each greeting. Read the log and keep only the sample's own lines: Change the greeting text in ContentView.swift and run lim xcode build . again. The new build replaces the app on the attached simulator. Clean up Delete both instances when you are done. Without an ID, delete removes the last instance of that type the CLI created for you: Instances you forget are deleted by your organization's inactivity timeout. Next steps Instances, tokens, labels, timeouts, and placement in one page. Schemes, sync rules, build settings, and artifact uploads. Every device action: taps, batches, recordings, logs, files, and camera. The same loop on Android, with ADB, scrcpy, and Android Studio. --- ## CLI URL: https://docs.limrun.com/docs/reference/cli Look up every lim command, the flags they share, and the environment variables the CLI reads. CLI reference The lim CLI drives every Limrun instance type from Linux, Windows, or macOS. This page lists every command in lim 0.35.1 and the behavior they share. For a command's full flag list, run it with --help: Install and update Install the lim package globally with npm, pnpm, or bun. The CLI needs Node.js 20 LTS or newer: Run the same command again to update, and lim version to print the installed version. Authenticate Every command that talks to Limrun resolves an API key in this order: 1. The --api-key flag. 2. The LIMAPIKEY environment variable. The CLI also reads it from a .env file in the working directory. 3. The key stored in ~/.lim/config.yaml by lim login (browser sign-in) or lim set-api-key. lim logout removes the stored key. Headless hosts such as CI runners and cloud agents should set LIMAPIKEY from a secret. A command pinned to a single instance through set-instance or the LIMINSTANCE_* variables needs no API key at all; see Sandboxed agents. Target an instance Device and build commands accept --id ; get and delete take the ID as a positional argument instead (lim ios delete ). Without an ID, they use the instance of that type you created last in the current workspace. The CLI records these per workspace in ~/.lim/last-instances.json; a workspace is the current git repository or worktree unless you assign one with lim set-workspace-dir, --workspace, or LIM_WORKSPACE. See The CLI's default instance. Pass --id explicitly in scripts, CI, and agents that create more than one instance. When the targeted instance no longer exists, device and build commands create a replacement automatically; pass --no-create to fail instead. create, delete, list, get, logs, tools, and version never create a replacement; they fail with not-found instead. Shared flags Commands that act on instances share these flags. Account commands such as lim login and lim skills install take only some of them. | Flag | What it does | |---|---| | --api-key | API key for this command. Overrides the stored login and LIMAPIKEY. | | --json | Print structured JSON instead of tables or plain text, where the command supports it. | | --quiet | Suppress intermediate logs and print only the final result. | | --workspace | Workspace used to find the most recent instance when no ID is given. Defaults to the current git repository or worktree. On lim xcode build and lim xcode test, --workspace is the .xcworkspace passed to xcodebuild; use LIM_WORKSPACE there. | | --[no-]create | Create a replacement instance when the target is not found. On by default. | | --[no-]daemon | Route device commands through a background WebSocket daemon that starts on first use, so repeated commands reuse one connection. On by default; disable with --no-daemon or LIM_DAEMON=false. | lim session start starts the same background connection explicitly for one instance, lim session status lists running sessions, and lim session stop stops one or all of them. Environment variables | Variable | Read by | Meaning | |---|---|---| | LIMAPIKEY | Every command | Organization API key. | | LIM_WORKSPACE | Every command | Workspace for resolving the default instance. | | LIM_DAEMON | Every command | Set to false to disable the background connection daemon. | | LIMDISABLETELEMETRY | Every command | Set to any value, such as 1, to disable CLI usage telemetry. | | LIMIOSINSTANCEURL, LIMIOSINSTANCETOKEN | lim ios | Drive one simulator by its API URL and instance token, without an API key. | | LIMANDROIDINSTANCEURL, LIMANDROIDINSTANCETOKEN, LIMANDROIDINSTANCEADBURL | lim android | The same for an emulator; the ADB URL enables lim android connect. | | LIMXCODEINSTANCEURL, LIMXCODEINSTANCETOKEN | lim xcode | The same for an Xcode sandbox. | | LIMGRADLEINSTANCEURL, LIMGRADLEINSTANCETOKEN | lim gradle | The same for a Gradle sandbox. | | LIMWEBHOOKURL, LIMWEBHOOKHEADERS, LIMWEBHOOKLABELS | lim xcode build, lim gradle build | Defaults for --webhook-url, --webhook-header, and --webhook-label. See Build logs and webhooks. | | LIMSIGNEDUPLOAD_URL | lim xcode build, lim gradle build | Default for --signed-upload-url when --upload is not set. | | LIMKEYSTOREPASSWORD, LIMKEYPASSWORD | lim gradle build | Passwords for --keystore. See Sign and publish. | | LIMPLAYSTOREACCESS_TOKEN | lim gradle build | Google access token for --upload-to-playstore. | Commands The Guide column links to the page that explains a command in context. Commands without a guide are covered by their --help output. iOS simulators | Command | What it does | Guide | |---|---|---| | lim ios app-log | Stream or tail app logs from a running iOS instance | Run a simulator | | lim ios app-logs | List persisted app log captures of an iOS instance | Run a simulator | | lim ios attach-xcode | Attach an Xcode instance to an iOS simulator | | | lim ios camera | Control the simulated camera of a running iOS instance | Run a simulator | | lim ios create | Create a new iOS instance | Run a simulator | | lim ios delete | Delete an iOS instance | Run a simulator | | lim ios delete-file | Delete a file or folder on the iOS simulator | Run a simulator | | lim ios element-tree | Get the UI element tree from a running iOS instance | Run a simulator | | lim ios events | List persisted event log captures of an iOS instance | Run a simulator | | lim ios fold | Read or change the native iPhone Duo fold state | Run a simulator | | lim ios get | Get details for a specific iOS instance | Per-instance MCP server | | lim ios info | Get device information from a running iOS instance | | | lim ios install-app | Install an app on a running iOS instance | Run a simulator | | lim ios keychain generate-key | Generate an iOS keychain encryption key | Run a simulator | | lim ios keychain restore | Restore iOS keychain state from asset storage | Run a simulator | | lim ios keychain save | Save iOS keychain state to asset storage | Run a simulator | | lim ios launch-app | Launch an app on a running iOS instance | Run a simulator | | lim ios list | List iOS instances | Run a simulator | | lim ios list-apps | List installed apps on a running iOS instance | Run a simulator | | lim ios ls | List files in the iOS simulator or an app container | Run a simulator | | lim ios lsof | List open files on a running iOS instance | Run a simulator | | lim ios maestro | Run Maestro flows against a running iOS instance | Maestro | | lim ios microphone | Control the simulated microphone of a running iOS instance | Run a simulator | | lim ios open-url | Open a URL on a running iOS instance | Run a simulator | | lim ios perform | Perform multiple iOS actions in a single batch | Run a simulator | | lim ios press-key | Press a key on a running iOS instance | Run a simulator | | lim ios pull-file | Pull a file from the iOS simulator | Run a simulator | | lim ios push-file | Push a local file to the iOS simulator | Run a simulator | | lim ios record | Start or stop video recording on a running iOS instance | Run a simulator | | lim ios recordings | List persisted session recordings of an iOS instance | Run a simulator | | lim ios reverse | Expose a local client-first service to the simulator | Connect to local services | | lim ios screenshot | Capture a screenshot from a running iOS instance | Run a simulator | | lim ios scroll | Scroll on a running iOS instance | Run a simulator | | lim ios set-instance | Set the iOS instance targeted by subsequent commands | Sandboxed agents | | lim ios set-text | Set text on an iOS element directly via accessibility | Run a simulator | | lim ios simctl | Run simctl on a running iOS instance | Run a simulator | | lim ios swipe | Swipe between two points on a running iOS instance | Run a simulator | | lim ios sync | Sync a built app bundle to a running iOS instance | | | lim ios syslog | Stream syslog from a running iOS instance | Run a simulator | | lim ios tap | Tap at coordinates on a running iOS instance | Run a simulator | | lim ios tap-element | Tap an iOS element by accessibility selector | Run a simulator | | lim ios terminate-app | Terminate an app on a running iOS instance | Run a simulator | | lim ios toggle-keyboard | Toggle the iOS software keyboard | Run a simulator | | lim ios tunnel | Expose declared local TCP destinations to the simulator | Connect to local services | | lim ios tunnel status | Show the active destination tunnel | Connect to local services | | lim ios tunnel stop | Stop the active destination tunnel | Connect to local services | | lim ios type | Type text into the focused iOS input field | Run a simulator | | lim ios xcodebuild | Run xcodebuild on a running iOS instance | Run a simulator | | lim ios xcrun | Run xcrun on a running iOS instance | Run a simulator | Android emulators | Command | What it does | Guide | |---|---|---| | lim android adb-shell | Run a shell command on a running Android instance (like adb shell) | Run an emulator | | lim android app-log | Stream or tail app logs from a running Android instance | Run an emulator | | lim android app-logs | List persisted app log captures of an Android instance | Run an emulator | | lim android camera | Control the virtual camera of a running Android instance | Run an emulator | | lim android connect | Connect to an existing Android instance via ADB tunnel | Run an emulator | | lim android create | Create a new Android instance | Run an emulator | | lim android delete | Delete an Android instance | Run an emulator | | lim android element-tree | Get the UI element tree from a running Android instance | Run an emulator | | lim android events | List persisted event log captures of an Android instance | Run an emulator | | lim android find-element | Find Android elements by selector | Run an emulator | | lim android get | Get details for a specific Android instance | | | lim android install-app | Install an app on a running Android instance | Run an emulator | | lim android launch-app | Launch an app on a running Android instance | Run an emulator | | lim android list | List Android instances | Run an emulator | | lim android logcat | Run logcat on a running Android instance | Run an emulator | | lim android open-url | Open a URL on a running Android instance | Run an emulator | | lim android play-on-microphone | Play a local audio file as microphone input on a running Android instance | Run an emulator | | lim android press-key | Press a key on a running Android instance | Run an emulator | | lim android pull-file | Pull a file from a running Android instance | Run an emulator | | lim android push-file | Push a local file to a running Android instance | Run an emulator | | lim android record | Start or stop video recording on a running Android instance | Run an emulator | | lim android recordings | List persisted session recordings of an Android instance | Run an emulator | | lim android screenshot | Capture a screenshot from a running Android instance | Run an emulator | | lim android scroll | Scroll on a running Android instance | Run an emulator | | lim android set-instance | Set the Android instance targeted by subsequent commands | Sandboxed agents | | lim android set-wifi-bandwidth | Set Wi-Fi bandwidth limits on a running Android instance | Run an emulator | | lim android sync | Sync an APK to a running Android instance | Run an emulator | | lim android tap | Tap at coordinates on a running Android instance | Run an emulator | | lim android tap-element | Tap an Android element by selector | Run an emulator | | lim android terminate-app | Terminate an app on a running Android instance | Run an emulator | | lim android tunnel | Send selected Android TCP destinations through this machine | Connect to local services | | lim android tunnel status | Show the active destination tunnel | Connect to local services | | lim android tunnel stop | Stop the active destination tunnel | Connect to local services | | lim android type | Type text into an Android input field | Run an emulator | Xcode sandboxes | Command | What it does | Guide | |---|---|---| | lim xcode attach-simulator | Attach an iOS simulator to an Xcode instance | Build with Xcode | | lim xcode build | Run xcodebuild on an Xcode sandbox | Build with Xcode | | lim xcode create | Create a new Xcode instance | Build with Xcode | | lim xcode delete | Delete an Xcode instance | GitHub Actions recipes | | lim xcode get | Get details for a specific Xcode instance | | | lim xcode list | List Xcode instances | PR previews | | lim xcode logs | Fetch logs for an active, recent, or specific Xcode build | Build logs and webhooks | | lim xcode rbe | Serve a local Bazel remote-execution endpoint backed by a Limrun Xcode instance | Build with Bazel | | lim xcode rbe upload | Upload the latest successful remote build as an asset | Build with Bazel | | lim xcode run | Run a command on a xcode sandbox | Build with Xcode | | lim xcode set-instance | Set the Xcode instance targeted by subsequent commands | Sandboxed agents | | lim xcode sync | Continuously sync local source code to an Xcode sandbox | Build with Xcode | | lim xcode test | Build a scheme's test targets on an Xcode sandbox and run them on an attached iOS simulator, streaming per-case results. Exits non-zero when tests fail. Creates and attaches the instances it needs unless --id points at an existing target | XCTest | | lim xcode tools | Show the selected developer tools in a xcode sandbox | Xcode and tool versions | | lim xcode tools install | Install selected tools in a xcode sandbox | Xcode and tool versions | | lim xcode use | Select developer tool versions for a xcode sandbox | Xcode and tool versions | | lim xcode version | Show the Xcode version the sandbox builds with | Xcode and tool versions | | lim xcode version list | List the Xcode versions the sandbox can build with | Xcode and tool versions | | lim xcode version set | Pick the Xcode version this workspace builds with | Xcode and tool versions | | lim xcode version unset | Forget the Xcode version preference of this workspace | Xcode and tool versions | Gradle sandboxes | Command | What it does | Guide | |---|---|---| | lim gradle build | Build an Android project on a gradle instance | Build with Gradle | | lim gradle create | Create a new gradle instance | Build with Gradle | | lim gradle delete | Delete a gradle instance | GitHub Actions recipes | | lim gradle get | Get a gradle instance | | | lim gradle list | List gradle instances | | | lim gradle logs | Fetch logs for an active, recent, or specific Gradle build | Build logs and webhooks | | lim gradle run | Run a command on a gradle sandbox | Build with Gradle | | lim gradle set-instance | Set the gradle instance targeted by subsequent commands | Sandboxed agents | | lim gradle sync | Sync local source code to a Gradle sandbox | | | lim gradle tools | Show the selected developer tools in a gradle sandbox | Build with Gradle | | lim gradle tools install | Install selected tools in a gradle sandbox | Build with Gradle | | lim gradle use | Select developer tool versions for a gradle sandbox | Build with Gradle | Assets | Command | What it does | Guide | |---|---|---| | lim asset delete | Delete an asset | Asset Storage | | lim asset get | List assets or get a specific one | | | lim asset list | List assets or get a specific one | Asset Storage | | lim asset pull | Download an asset file | Asset Storage | | lim asset push | Upload an asset file | Asset Storage | Skills, sessions, and account | Command | What it does | Guide | |---|---|---| | lim login | Log in to Limrun | | | lim logout | Log out of Limrun | | | lim run | Get started with Limrun | | | lim session start | Start a persistent session that keeps one connection open to an instance | | | lim session status | Show active sessions | | | lim session stop | Stop one or all active sessions | | | lim set-api-key | Set the stored Limrun API key | | | lim set-workspace-dir | Assign a directory to an isolated lim workspace | | | lim skills install | Install Limrun skills for AI coding agents | Set up a coding agent | | lim version | Print the installed CLI version | | --- ## Egress IP addresses URL: https://docs.limrun.com/docs/reference/egress-ip-addresses Allowlist Limrun instances in a firewall or third-party service that accepts traffic only from known source IPs. Egress IP addresses Limrun instances send outbound traffic through shared regional egress IP addresses. Allowlist every address for the region your instance runs in. These addresses are for outbound traffic from Limrun instances to your systems. They do not describe inbound access to Limrun APIs, browser streaming URLs, or per-organization egress. When to use these addresses Use this list when a system you manage requires source IP allowlisting before it accepts traffic from Limrun. Common examples are internal APIs, package registries, SFTP servers, database proxies, webhook receivers, and third-party services behind firewall or security group rules. To reach a service that only your machine or VPN can resolve, a tunnel (iOS, Android) is often simpler than an allowlist. Treat allowlisting as a network control IP allowlisting is an extra network control, not authentication. Keep API keys, mTLS, signed requests, or other application-level authentication in place for any service that accepts traffic from Limrun. The addresses below are shared by Limrun infrastructure in each region. They are not assigned to a single customer or organization. Traffic from a region may use any address listed for that region, so allowlist the full regional set. Egress IPs by region Download as Markdown Updates Last updated: September 28, 2026. When these addresses change, update your allowlists before you depend on the new region or gateway pool. If you need per-organization egress IPs, contact Limrun support. --- ## Instance spec and status URL: https://docs.limrun.com/docs/reference/instances Look up any field you can set when creating an instance, or any field it reports back while it runs. Instance spec and status Every create call takes metadata and spec, and every instance reports its URLs and state in status. The spec tables cover iOS simulators and Android emulators; the lifecycle, status, timeout, and placement rules apply to Xcode and Gradle sandboxes too. Build-specific settings for sandboxes live in Build with Xcode and Build with Gradle. For what the fields mean in practice, start with Concepts; for the calls themselves, see SDKs and REST API. Lifecycle states Every iOS, Android, Xcode, and Gradle instance moves through the same lifecycle: creating while hardware is provisioned, assigned while it boots, ready when the URLs in status are usable, and finally terminated. | State | Meaning | |---|---| | unknown | Default placeholder; not returned for live instances. | | creating | Provisioning. URLs in status are not populated yet. | | assigned | Hardware is assigned and the instance is booting. | | ready | Fully booted. All URLs are populated, and a control client can connect. | | terminated | Stopped by an explicit delete, the inactivity timeout, the hard timeout, or an error. | status.errorMessage is set when the instance terminated because of an error. A create call with wait: true returns once the instance is ready. Status fields A ready instance carries the URLs its data plane needs in status. Every URL authenticates with the instance token, never the API key. | Field | iOS | Android | Xcode and Gradle | Use | |---|---|---|---|---| | state | ✓ | ✓ | ✓ | The lifecycle state. | | token | ✓ | ✓ | ✓ | Instance token for apiUrl, endpointWebSocketUrl, adbWebSocketUrl, and mcpUrl. Embedded in signedStreamUrl. Returned only to a credential that can control the instance; a read-only API key or a viewer seat gets no token. | | streamToken | ✓ | ✓ | | Streaming-only token: opens endpointWebSocketUrl and nothing else, so a browser can hold it while status.token stays on your backend. Returned with token; get and list return it only while the instance runs. | | apiUrl | ✓ | ✓ | ✓ | HTTP base for the instance's daemon; pass it to the SDK and CLI control clients. On Android, the control WebSocket derives from it as apiUrl + /ws, and recording downloads use the same base. | | signedStreamUrl | ✓ | ✓ | | Console-hosted watch URL with the token in the URL fragment, so the token never reaches a server. Opens in a browser without an API key or sign-in. | | endpointWebSocketUrl | ✓ | ✓ | | WebSocket for the ` component; pass ?token=. | | mcpUrl | ✓ | ✓ | | The instance's MCP HTTP endpoint. Authenticate with status.token as Authorization: Bearer. See Per-instance MCP server; for one endpoint that also creates instances, see the remote MCP server. | | adbWebSocketUrl | | ✓ | | ADB tunnel endpoint. Pass it as adbUrl to createInstanceClient. | | targetHttpPortUrlPrefix | ✓ | ✓ | | Prefix for reaching HTTP services that run on the device. Append the target port, with no slash, to forward traffic; Appium uses it to reach WebDriverAgent. | | sandbox.playwrightAndroid.url | | ✓ (when enabled) | | Chrome DevTools Protocol URL when spec.sandbox.playwrightAndroid.enabled is true. See Playwright. | | sandbox.xcode.url | ✓ (legacy) | | | Embedded Xcode sandbox URL on instances that old clients created with spec.sandbox.xcode.enabled. The TypeScript SDK and CLI no longer offer it; create the Xcode sandbox separately and attach the simulator. See Move off the embedded Xcode sandbox. | | errorMessage | ✓ | ✓ | ✓ | Set when the instance terminated because of an error. | | terminationReason | ✓ | ✓ | ✓ | Present once state is terminated, never before. Known values: UserRequested (a delete request), InactivityTimeout, HardTimeout, and Unknown (a cause the platform did not attribute, including instances that failed to become ready; see errorMessage). New values may be added, so treat unrecognized ones as Unknown. | The iOS and Android status blocks otherwise have the same shape. Reaching a server that runs inside the simulator (through targetHttpPortUrlPrefix) is the opposite direction from letting the device reach your machine; for that, see Connect to local services for iOS or Android. Example create response A successful Android create with wait: true returns the full instance resource. An iOS response has the same shape without adbWebSocketUrl: The metadata and spec fields in the response: | Field | Use | |---|---| | metadata.id | Stable instance identifier. Pass it to get, delete, and connect. | | metadata.labels | Echoed back. Drives reuseIfExists matching and label-selector listing. | | metadata.displayName | Human-readable name shown in the console. | | spec.region | The region the scheduler placed the instance in. | | spec.inactivityTimeout | Returned as a Go duration string ("10m0s"), not the "10m" you passed. | Create parameters Every create call, for any instance type, accepts these parameters next to spec: | Field | Type | Meaning | |---|---|---| | wait | boolean | When true, the call returns only after status.state === 'ready'; without it, poll. Default false. | | reuseIfExists | boolean | When true, return the existing instance whose metadata.labels match exactly instead of creating one. The lookup runs in the region that handles the create, so a placement change can miss. Default false. | | metadata.labels | Record | Free-form key=value map. Powers reuseIfExists, labelSelector listing, and bulk cleanup. See Labels. | | metadata.displayName | string | Human-readable name shown in the console next to metadata.id. | Timeouts Two timeouts end an instance without a delete call. Both take a duration string such as 1m, 10m, or 3h. - spec.inactivityTimeout terminates the instance after this long without activity. The timer starts once the instance is ready. Omitted or '0', it uses your organization's default for the platform: new organizations start at 3 minutes for iOS and Android and 10 minutes for Xcode, and Gradle sandboxes default to 10 minutes. An organization default of 0 means instances are never terminated for inactivity. The maximum is 24h. - spec.hardTimeout terminates the instance after this much wall-clock time, active or not. Default '0', no cap. A terminated instance reports which one fired in status.terminationReason. iOS spec The spec fields accepted by iosInstances.create (TypeScript names; Python uses snake_case). The CLI flags for the same fields are in Run a simulator. | Field | Type | Meaning | |---|---|---| | spec.model | 'iphone' \| 'iphone-duo' \| 'ipad' \| 'watch' | Apple Simulator model. Default iphone. iphone-duo must be available to your organization. The Go SDK does not expose this field. | | spec.jurisdiction | 'us' \| 'eu' \| 'as' | Hard constraint: schedule only inside this jurisdiction; creation fails when no region there has capacity. TypeScript SDK, CLI, and REST only. See Placement. | | spec.region | string | Deprecated. A region preference such as us-east1, not a hard pin. See Placement. | | spec.inactivityTimeout | duration string | See Timeouts. | | spec.hardTimeout | duration string | See Timeouts. | | spec.forceBundleId | string | After this app first enters the foreground, Limrun brings it back whenever it is closed or backgrounded. It does not launch the app at startup. | | spec.clues[] | array | Placement hints: ClientIP with the end user's IP, or ClientLocation with coordinates (TypeScript SDK and REST only). See Client location clues. | | spec.initialAssets[] | array | Apps or keychain state to apply at boot. App entries use kind: 'App' with source URL, AssetName, or AssetID, plus an optional launchMode of ForegroundIfRunning or RelaunchIfRunning; omit launchMode to install without launching. Keychain entries use kind: 'Keychain' and require encryptionKey. See Pre-install at boot and Save and restore the keychain. | | spec.sandbox.xcode.enabled | boolean | Legacy: create an embedded Xcode sandbox with the simulator. The API still accepts it for old clients, but the TypeScript SDK (from 0.54.0) and CLI (from 0.35.0) no longer offer it. Create the sandbox separately and attach instead; see Move off the embedded Xcode sandbox. | Android spec The spec fields accepted by androidInstances.create. The CLI flags for the same fields are in Run an emulator. | Field | Type | Meaning | |---|---|---| | spec.model | 'phone' \| 'tablet' | Android device model. Default phone. TypeScript SDK, CLI, and REST only; the Python and Go SDKs do not expose it. | | spec.jurisdiction | 'us' \| 'eu' \| 'as' | Hard constraint: schedule only inside this jurisdiction; creation fails when no region there has capacity. TypeScript SDK, CLI, and REST only. See Placement. | | spec.region | string | Deprecated. A region preference such as us-east1, not a hard pin. See Placement. | | spec.inactivityTimeout | duration string | See Timeouts. | | spec.hardTimeout | duration string | See Timeouts. | | spec.clues[].kind | 'ClientIP' \| 'ClientLocation' \| 'OSVersion' | Placement hint: bias by Android version or by the end user's location. | | spec.clues[].clientIp | string | Required when kind: 'ClientIP'. The IP of the end user whose browser streams the emulator; the scheduler geolocates it and provisions in the nearest region. spec.region overrides this clue. | | spec.clues[].clientLocation | object | { latitude, longitude } in decimal degrees when kind: 'ClientLocation'. Both coordinates are required. Takes precedence over ClientIP. TypeScript SDK and REST only. | | spec.clues[].osVersion | string | Required when kind: 'OSVersion'. The Android major version, '14' or '15'. | | spec.initialAssets[] | array | APKs to install while provisioning, plus Configuration entries such as Chrome flags. Plural sources install split APKs as one group. See Pre-install APKs. | | spec.sandbox.playwrightAndroid.enabled | boolean | Provision a Playwright sub-sandbox next to the emulator and report its CDP URL in status.sandbox.playwrightAndroid.url. Default false. See Playwright. | | spec.sandbox.playwrightAndroid.version | '1.56.1-lim.1' \| '1.60.0-lim.1' | Playwright server version for the sub-sandbox. Omit it for 1.56.1-lim.1. Your Playwright client version must match. | The CLI's --os-version and --model flags set the OS version and model. It has no flags for the ClientIP and ClientLocation clues or for spec.sandbox.playwrightAndroid; set those from an SDK or REST. Labels metadata.labels is a free-form { [key: string]: string } map used for reuse, listing, and cleanup; Labels and reuse explains how. Avoid keys that could collide with metadata Limrun adds itself, such as the created-by: mcp label on instances the remote MCP server creates. Placement spec.jurisdiction (us, eu, or as) keeps an instance inside one jurisdiction. It is a hard constraint: creation fails when no region there has capacity. spec.region is deprecated. It is a preference, such as eu-north1 or us-east1, that is tried first and overflows to other regions by proximity when the preferred ones are full. When both are omitted, Limrun places the instance by spec.clues and current availability. jurisdiction is available in the TypeScript SDK and the CLI today; the Python and Go SDKs expose only region. The console's Analytics tab breaks down runtime minutes per platform and per region, which shows which regions your organization has used: Client location clues When you create an Android, iOS, Xcode, or Gradle instance through the REST API, supply the end user's coordinates as a ClientLocation clue to prefer a nearby region: Both coordinates are required and use decimal degrees. Latitude must be between -90 and 90, and longitude between -180 and 180, inclusive. Zero is valid. Missing or invalid coordinates return HTTP 400. ClientLocation takes precedence over ClientIP regardless of their order in spec.clues. Without coordinates, pass { "kind": "ClientIP", "clientIp": "203.0.113.42" } to locate the end user by IP. For requests without either clue, Limrun uses available visitor coordinates first, then the connecting IP. Location clues express a preference. spec.jurisdiction still constrains placement, and a full region can lead to creation in another eligible region. A specific spec.region overrides location clues. Use the REST payload above if your installed SDK does not expose ClientLocation` yet. For embedded devices, always pass the end user's location or IP. It places the instance near the person streaming it, which is the difference between a responsive device and one that lags across continents. Next steps Instances, credentials, labels, and placement explained. The create, list, get, and delete calls in every language. --- ## SDKs and REST API URL: https://docs.limrun.com/docs/reference/sdk Call Limrun from your own code in TypeScript, Python, or Go, or over plain REST. SDKs and REST API Every Limrun operation is a REST call on api.limrun.com, and the TypeScript, Python, and Go SDKs wrap those calls with typed clients. The OpenAPI spec is the source of truth, and each SDK repository carries a generated method reference in its api.md (TypeScript, Python, Go). The SDKs are generated from the spec, so method names and parameter shapes match across languages once you account for casing: camelCase in TypeScript, snake_case in Python, PascalCase in Go. For the fields inside spec and status, see Instance spec and status. Install Install the SDK for your language: To skip the SDK, call the API with curl; no install is required. The lim CLI installs separately with npm install --global lim; see the CLI reference. Base URL All REST calls go to one base URL: Each SDK client accepts an option to override it. Authentication Every control-plane request authenticates with an API key (prefix lim). Create keys at console.limrun.com under **Settings → API Keys**. The CLI and every SDK read the key from the LIMAPI_KEY environment variable by default. The SDK clients take the key as a constructor argument, which can be omitted when LIMAPIKEY is set; with curl, pass it as a bearer token: Credentials The SDKs use the credentials described in Concepts. Two SDK specifics matter: - The client constructor takes the API key and calls the control plane on api.limrun.com. Keep it on your backend; never ship it to a browser or an end-user client. - Device clients such as Ios.createInstanceClient and createInstanceClient (Android) take the instance's status.apiUrl and its instance token, status.token, and need no API key. Instance tokens are returned only to credentials that can control the instance; a read-only API key or a viewer seat gets no token. status.streamToken opens only endpointWebSocketUrl, so a browser can stream the device while status.token stays on your backend. For embedded devices, your backend passes endpointWebSocketUrl and a token to `; see Embed a device. Scoped tokens A scoped token authorizes a browser for device installation or Apple account flows, limited to the scopes you list. Mint it on your backend: The token inherits the API key's organization. ttlSeconds defaults to 3600 and accepts values from 1 through 14400. Mint short-lived tokens and hand them to the browser only when it starts a registry session. If one leaks before it expires, revoke it with the id from the REST response of POST /v1/scoped_tokens. The TypeScript ScopedToken type does not declare id yet, so read it from the raw response: The registry refuses the token from the next request onwards. Revocation covers that one token: the API key that minted it and every other token minted with it keep working. SDK capability matrix The SDKs are not equivalent. The TypeScript SDK covers every surface. The Python SDK covers iOS, Android, and Xcode instances and assets; use REST or the CLI for the rest. The Go column describes the released Go SDK v0.9.0, which covers the control plane, part of iOS device control, and the ADB tunnel. The lim CLI covers everything and is the fallback for gaps: shell out to it from your code, or call the REST URLs returned in instance.status. Starting a destination tunnel needs the CLI, the TypeScript SDK, or an implementation of its multiplexed WebSocket protocol; raw HTTP is not enough. | Capability | TypeScript | Python | Go | |---|---|---|---| | iOS and Android instance create, list, get, delete | ✓ | ✓ | ✓ | | Xcode instance create, list, get, delete | ✓ (xcodeInstances) | ✓ (xcodeinstances) | POST /v1/xcodeinstances over REST, or lim xcode create | | Gradle instance create, list, get, delete | ✓ (gradleInstances) | REST, or lim gradle create | REST, or lim gradle create | | Asset create, list, get, delete | ✓ | ✓ | ✓ | | assets.getOrUpload (MD5 dedup plus signed PUT) | ✓ | upload manually | ✓ (Assets.GetOrUpload) | | assets.getOrCreate (signed upload URL) | ✓ | ✓ (getorcreate) | ✓ (Assets.GetOrNew) | | Scoped tokens (scopedTokens.create) | ✓ | REST | REST | | Usage analytics (analytics.get, analytics.getInstances) | ✓ | REST | REST | | Asset ttl on upload | ✓ | not exposed | not exposed | | Asset list namePrefixFilter | ✓ | not exposed (name_filter only) | not exposed (NameFilter only) | | Xcode source sync, remote xcodebuild, one-shot commands | ✓ (xcodeInstances.createClient) | lim xcode build | lim xcode build | | Gradle source sync and remote builds | ✓ (gradleInstances.createClient) | lim gradle build | lim gradle build | | Escrowed Android release signing (--sign) | pass the signing material yourself | lim gradle build --sign | lim gradle build --sign | | iOS device control (taps, screenshot, element tree, typing, simctl streaming) | ✓ | CLI or REST | ✓ (subset, see below) | | Destination tunnel, iOS and Android (startTunnel, getTunnelStatus, stopTunnel; the Android client needs adbUrl; see iOS and Android) | ✓ | not supported | not supported | | iOS reverse tunnel (startReverseTunnel) | ✓ | not supported | not supported | | iOS app-log streaming | ✓ | not supported | not supported | | iOS video recording | ✓ | not supported | not supported | | iOS camera from a video file (setCameraVideo) | ✓ | not supported | not supported | | StoreKit local-config helpers | ✓ | not supported | not supported | | Android device-control client (screenshot, element tree, tap, type, scroll, open URL) | ✓ | adb over the tunnel | adb over the tunnel | | Android video recording | ✓ (startRecording / stopRecording) | adb screenrecord | adb screenrecord | | Android APK install on a running instance | ✓ (sendAsset) | adb install or lim android install-app | adb install or lim android install-app | | Android connection lifecycle hooks | ✓ | not exposed | not exposed | | ADB tunnel for Android | ✓ (startAdbTunnel) | external adb via lim android connect | ✓ (tunnel.NewADB, plus Multiplexed) | | Playwright Android sub-sandbox at create time | ✓ | ✓ | ✓ | | spec.jurisdiction on create | ✓ | region only | region only | | spec.model on iOS create | ✓ | ✓ | not exposed; defaults to iPhone, use the CLI or REST | | spec.model on Android create | ✓ | not exposed | not exposed | | ClientLocation clue | ✓ | not exposed | not exposed | In Go SDK v0.9.0, the iOS WebSocket client (github.com/limrun-inc/go-sdk/websocket/ios) has Screenshot, ElementTree, Tap, TapElement, IncrementElement, DecrementElement, SetElementValue, TypeText, PressKey, LaunchApp, ListApps, OpenURL, InstallApp, Lsof, SetOrientation, and Simctl streaming. It has no terminate-app, scroll, batched actions, keyboard toggle, video recording, log streaming, or StoreKit helpers. Errors Errors return JSON with an HTTP status code. The TypeScript and Python SDKs map each status to a typed error class: | Status | TypeScript | Python | Meaning | |---|---|---|---| | 400 | BadRequestError | BadRequestError | Malformed input or an invalid combination of parameters. | | 401 | AuthenticationError | AuthenticationError | Missing or invalid API key. | | 403 | PermissionDeniedError | PermissionDeniedError | The API key lacks the necessary permission. | | 404 | NotFoundError | NotFoundError | The instance, asset, or other resource does not exist. | | 409 | ConflictError | ConflictError | The request conflicts with the resource's current state. Deleting an instance that is already gone is not a conflict; it returns 200. | | 422 | UnprocessableEntityError | UnprocessableEntityError | The request shape is valid but semantically rejected. | | 429 | RateLimitError | RateLimitError | Rate limit hit. Retry with backoff. | | ≥500 | InternalServerError | InternalServerError | Server-side issue. The SDKs retry automatically. | | n/a | APIConnectionError | APIConnectionError | Network failure before a response. | | n/a | APIConnectionTimeoutError | APITimeoutError | The request timed out. | | n/a | APIUserAbortError | none | The caller aborted the request. | The Go SDK returns a single limrun.Error for every non-success status, carrying StatusCode, the request, the response, and the JSON error body; match it with errors.As. Transport failures come back unwrapped, for example a url.Error. All three SDKs retry connection errors, 408, 409, 429, and 5xx responses, 2 times by default with exponential backoff. Catch API errors in TypeScript like this: Timeouts, retries, and logging Each SDK sets timeouts and retries on the client and lets you override them per request: | SDK | Default timeout | Default retries | Options | |---|---|---|---| | TypeScript | 5 minutes | 2 | timeout, maxRetries | | Python | 5 minutes (5 s to connect) | 2 | timeout, max_retries | | Go v0.9.0 | none; set one with a context deadline | 2 | option.WithRequestTimeout (per attempt), option.WithMaxRetries | In Go, a context timeout covers the request including all retries; WithRequestTimeout limits each attempt. The TypeScript SDK also accepts a logger and a logLevel ('debug' | 'info' | 'warn' | 'error' | 'off'), which the LIMRUN_LOG environment variable can set. 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 think before enabling debug logging in production. The wait and reuseIfExists parameters Every create call accepts two query parameters: | Parameter | Type | Meaning | |---|---|---| | wait | boolean | Return only after the instance reaches ready. Without it, the call returns at once with state: 'creating'. | | reuseIfExists | boolean | If an instance with exactly the same labels exists in the region that handles the create, return it instead of creating a new one. | Use wait: true for interactive workflows that need the URLs right away. Use reuseIfExists: true when repeated calls, such as CLI re-runs, retries, or CI runs of the same PR, should converge on one instance. Placement follows the caller's location and capacity, so a call from a different network can land in another region and start a fresh instance. See Labels and reuse. iOS instances The iOS calls below show every language. The other resources follow the same shape. Create A create call with wait, reuseIfExists, labels, and a few spec fields: forceBundleId does not launch the app when the instance starts. After the app first enters the foreground, Limrun brings it back whenever it is closed or backgrounded. List List calls filter by label selector and state. The SDK instance list methods page automatically; see Pagination. Get and delete Get and delete take the instance ID: CLI only: lim ios delete without an ID deletes the last iOS instance the CLI created in this workspace. Android instances Android calls have the same shape as iOS. Substitute androidInstances (TypeScript), android_instances (Python), AndroidInstances (Go), or lim android (CLI). The Android-specific pieces are the OSVersion clue ({ kind: 'OSVersion', osVersion: '14' | '15' }), the plural sources on initialAssets for split APKs, Chrome flag configuration entries, and spec.sandbox.playwrightAndroid.enabled. See Run an emulator. Xcode and Gradle instances The TypeScript SDK exposes xcodeInstances and gradleInstances with the same create, list, get, and delete shape as iOS, plus createClient for sync and builds. On an Xcode client, xcode.setXcode('27') binds the newest Xcode 27 GA release, xcode.setXcode('27.1') pins exactly 27.1, which may be a beta, and each entry in xcode.getXcode().installed carries a channel of 'ga' or 'beta'. See Xcode and tool versions. The Python SDK has xcode_instances for create, list, get, and delete, but no sync or build client. The Go SDK has neither resource. From Go, or for Gradle from Python: - Call POST /v1/xcode_instances (or the Gradle equivalent) over HTTP. - Or shell out to lim xcode create and lim xcode build, or lim gradle create and lim gradle build. To run a build on a simulator, attach the simulator to the Xcode instance after creating both; see Build with Xcode. Assets Assets use getOrCreate (an upsert, named getorcreate in Python and GetOrNew in Go) and getOrUpload (an upsert plus the upload) instead of create. The platform field is optional; leaving it unset makes the asset available on every platform. The getOrUpload helper, which handles MD5 deduplication and the signed PUT, ships in the TypeScript and Go SDKs. From Python, call getorcreate, then PUT the bytes to signeduploadurl when md5 is missing or differs. Upload, list, get, and delete assets: The cURL tab shows only the two-step upload. lim asset pull downloads an asset's file. Asset Storage covers expiry, installs, and filters. Pagination In TypeScript, instance list methods return iterables that fetch further pages as needed, and a single page also exposes hasNextPage() and getNextPage(). In Python and Go, page by hand as the List examples show: pass the last instance ID of a page as starting_after (Python) or StartingAfter (Go), and stop at an empty page. The Python iterator stops after the first page, and Go ListAutoPaging panics on instance lists. limit sets the page size: it defaults to 50 and caps at 500, and a larger value returns 500 per page. assets.list does not page. It returns one array of at most limit assets (default 50), so repeat the call until it comes back empty when you need every asset; Asset Storage shows the pattern. Usage analytics Use client.analytics.get() for usage totals and client.analytics.getInstances() for per-instance billing details. Analytics is available in the TypeScript SDK and over REST, at GET /v1/analytics and GET /v1/analytics/instances. Instances appear after billing is recorded. Filter by time startedAt and stoppedAt accept independent RFC3339 timestamp bounds, and all supplied bounds apply together: | Bound | Meaning | |---|---| | gte | At or after the timestamp. | | lte | At or before the timestamp. | To retrieve instances that stopped during a window, regardless of when they started, supply only stoppedAt: To narrow that to instances that started at or before the window start, add startedAt: In REST query strings, encode nested bounds as parameters such as startedAt[lte] and stoppedAt[gte]. At least one time bound is required; one-sided bounds work. Time buckets are grouped by instance start time. A bounded start-time range, from startedAt.gte and startedAt.lte, includes empty buckets; queries without one return only populated buckets. The older top-level from and to parameters still work and mean startedAt >= from and startedAt < to. They intersect with the new bounds, count as a bounded start-time range, and are echoed in responses when supplied. Historical stoppedAt values approximate with the time billing was recorded, which can be after termination. For periodic imports, rescan overlapping windows and upsert results by instanceTid, and keep a reconciliation scan of older windows to catch bills delayed beyond the overlap. Next steps Every create field, state, and status URL. Every lim command, shared flags, and environment variables. The device-control surface behind instance.status.apiUrl`. The ADB tunnel and the Android device-control client. --- ## Single sign-on URL: https://docs.limrun.com/docs/reference/single-sign-on Let your team sign in to Limrun through your identity provider with OIDC. Single sign-on with OIDC Limrun acts as an OIDC service provider. It uses each user's verified email domain to select the organization's OIDC connection. Once a domain is verified and its organization has an SSO connection, everyone with that email domain must use Continue with SSO. This applies to new signups and existing accounts. Google, GitHub, Microsoft, and email-code sign-in cannot be used for that domain. For provider-specific setup, see Okta or Microsoft Entra ID. To manage users and group access, see SCIM provisioning. Before you start Before you configure SSO, make sure you have: - Admin access to the Limrun organization. - Permission to create an OIDC web application in your identity provider. - Access to the DNS provider for the email domain your users sign in with. - An email domain that belongs to the Limrun organization. Supported features Limrun supports: - OIDC authorization code flow - SP-initiated SSO - IdP-initiated SSO through OIDC third-party initiated login - Just-In-Time provisioning - Single Logout - Universal Logout through Global Token Revocation Verify your email domain Each SSO domain must be verified before users with that domain can sign in through the OIDC connection. Verification needs access to your DNS records; if that is not possible, contact Limrun support for manual verification. 1. Open console.limrun.com. 2. Go to Settings. 3. In Domain Verification, add the email domain that should use SSO. 4. Create the TXT record shown in the verification dialog: | DNS field | Value | |---|---| | Type | TXT | | Host / Name | Copy from Limrun. | | Value | Copy from Limrun. | 5. After the DNS record is published, click Verify DNS in Limrun. New domains have auto-join enabled by default. Auto-join takes effect after verification. If you manage domains through the API, set autoJoinEnabled: false to opt out. Without SCIM, successful SSO sign-in adds the user to the connection's organization regardless of this setting. Connecting SCIM disables auto-join and new SSO admission during setup. Once activated, SCIM requires an active provisioned user and a mapped group. Create the OIDC application 1. In your identity provider, create a new OIDC application. 2. Choose Web Application as the application type. 3. Use the authorization code flow. 4. Make sure the ID token includes the user's email claim. 5. Name the app Limrun. 6. Add this sign-in redirect URI: 7. If your provider asks for an initiate login URI, add: 8. Assign the users or groups that should have access to Limrun. 9. Save the app. Copy the OIDC values Copy these values from your identity provider: | Limrun field | Provider value | |---|---| | Issuer URL | The issuer from the provider's OpenID configuration. | | Client ID | The app's client ID. | | Client Secret | The app's client secret. | The issuer URL is the issuer value from your provider's OpenID configuration document. For example, with Okta: Use the issuer value from that JSON document: Save the connection in Limrun 1. Return to console.limrun.com. 2. Go to Settings > Single Sign-On. 3. Confirm the redirect URI shown in Limrun matches the redirect URI in your identity provider. 4. Enter the Issuer URL, Client ID, and Client Secret. 5. Click Save. Limrun discovers the provider from the issuer URL and requests the openid, email, and profile scopes. Configure Single Logout (optional) If your identity provider supports OIDC Single Logout, add the Post Logout Redirect URI shown in Limrun to your OIDC application's allowed logout redirect URIs. For production, this value is: When a user signs out of Limrun, Limrun revokes the user's Limrun token and redirects through the provider's endsessionendpoint when the provider publishes one. Configure Universal Logout (optional) If your identity provider supports Global Token Revocation, configure this endpoint: Limrun accepts these subject identifier formats: | Format | How Limrun resolves it | |---|---| | iss_sub | Matches the OIDC issuer and the user's OIDC sub. | | email | Matches the user's email after confirming the domain belongs to the OIDC connection's organization. | Sign in For SP-initiated sign-in, users start from the Limrun console: 1. Go to console.limrun.com/authn/login. 2. Click Continue with SSO. 3. Enter your work email address. 4. Complete authentication in your identity provider. If the email domain is verified and the provider sign-in succeeds, Limrun signs the user in and redirects them to the console. Troubleshooting Users see "Your organization requires SSO". Choose Continue with SSO and enter the work email address. Users who previously signed in with Google or another method can use the same email through SSO to keep their existing account. Users see "SSO not configured". Confirm the user's email domain is added under Settings > Domain Verification and shows Verified. Limrun only starts OIDC SSO for verified domains. The provider rejects the redirect URI. Confirm that the redirect URI is exactly https://api.limrun.com/authn/oidc/callback. The provider returns an ID token without an email. Configure the OIDC app so the ID token includes the user's email claim. Limrun requires the email claim to match the user to a verified organization domain. A user authenticates with the provider but cannot enter Limrun. Confirm the user's email domain is verified in Limrun and assigned to the same organization as the OIDC connection. Limits and account linking - Limrun supports OIDC SSO. For user and group provisioning, see SCIM provisioning. SAML is not supported. - A Limrun organization has one OIDC connection. - Without SCIM, a first SSO sign-in creates an account and joins the organization. With SCIM, provisioning creates the account before sign-in. New users can sign in after activation when they are active in the directory and belong to a group mapped to a Limrun role. - If a user already exists with the same verified email address, a successful OIDC sign-in links that account to the OIDC identity for the organization. --- ## Microsoft Entra ID URL: https://docs.limrun.com/docs/reference/single-sign-on/microsoft-entra Let your team sign in with Microsoft Entra ID and manage their Limrun access from Entra. Microsoft Entra ID Connect your Microsoft Entra tenant to Limrun with OIDC SSO. To manage organization membership from Entra, also configure SCIM through a non-gallery enterprise application. This setup does not require a Limrun gallery listing. This guide uses two applications in your tenant: Limrun SSO for authentication and Limrun SCIM for provisioning. Keep their user and group assignments aligned. Prerequisites - Admin access to the Limrun organization and DNS access for its email domains. - Entra permissions to create app registrations, grant admin consent, manage credentials, assign users, and configure enterprise application provisioning. - An Entra license that supports the provisioning and group assignment features you plan to use. Check Microsoft's licensing guidance. - Work email addresses populated in Entra that belong to a verified Limrun domain. Configure SSO Verify your domain in Limrun 1. Open console.limrun.com and go to Settings > Domain Verification. 2. Add the email domain your users sign in with. 3. Publish the TXT record shown in Limrun, then select Verify DNS. Once you save an SSO connection, users with that verified domain must use Continue with SSO, including existing users. The separate Microsoft social sign-in flow does not satisfy the organization's SSO requirement. Register the SSO application 1. Open the Microsoft Entra admin center in your organization's tenant. 2. Go to App registrations > New registration and name the app Limrun SSO. 3. Select Accounts in this organizational directory only. 4. Add a Web redirect URI: 5. Register the application. Copy its Application (client) ID and Directory (tenant) ID. 6. Under Certificates & secrets, create a client secret and copy its Value. The secret ID is not the secret value. Record its expiration date so you can replace it in Limrun before it expires. Use the Web platform and authorization code flow. Limrun does not need implicit grant enabled. For registration details, see Microsoft's app registration guide. Configure the email claim and assignments 1. In the app registration, open Token configuration > Add optional claim. 2. Select ID, add email, and save. If prompted, enable the permission needed to include the claim. 3. Confirm each assigned user's email is populated and belongs to a verified Limrun domain. The ID token must contain email; Limrun does not fall back to preferred_username or the user's principal name. 4. Open the corresponding Limrun SSO application under Enterprise applications. Under Properties, set Assignment required? to Yes and save. 5. Under Users and groups, assign the users or groups allowed to sign in, including the administrator configuring Limrun. 6. Return to the app registration's API permissions. Add Microsoft Graph delegated permissions for openid, email, and profile, then select Grant admin consent for your tenant. Assignment-required apps need admin consent before assigned users can sign in. See Microsoft's guides to optional claims and assignment and consent. Save the Entra connection in Limrun Open Settings > Single Sign-On and enter: | Limrun field | Value | |---|---| | Issuer URL | https://login.microsoftonline.com//v2.0, replacing ` with your Directory ID. | | Client ID | Application ID of Limrun SSO. | | Client Secret | The secret Value from that app registration. | Select Save. Use the tenant-specific issuer, not /common or /organizations. You can confirm the issuer in https://login.microsoftonline.com//v2.0/.well-known/openid-configuration. Log in with Microsoft Entra ID 1. Open console.limrun.com/authn/login. 2. Select Continue with SSO and enter your work email. 3. Complete authentication in your organization's Microsoft tenant. Without SCIM, successful SSO creates or links the account and joins the organization. If you plan to use SCIM, test with an existing Limrun admin, then complete provisioning before inviting users to sign in. Configure SCIM provisioning Entra's non-gallery provisioning supports a SCIM URL and bearer token. The steps below use Limrun's generated credential directly. See Microsoft's non-gallery SCIM guide. Create the provisioning application 1. In Entra, open Enterprise applications > New application > Create your own application. 2. Name it Limrun SCIM and choose Integrate any other application you don't find in the gallery. 3. In Limrun, open Settings > Single Sign-On > Directory provisioning (SCIM) and generate a credential. 4. In Limrun SCIM, open Provisioning and configure automatic provisioning. Depending on your Entra portal, select Get started or New configuration. 5. Enter Limrun's SCIM base URL as the Tenant URL, and its generated credential as the Secret Token. Copy the credential without a Bearer prefix. 6. Select Test Connection, then save. The SCIM credential is separate from the Limrun SSO client secret and Limrun API keys. Generating it starts SCIM setup: existing members retain access, while invitations, auto-join, and admission of new users are disabled. Configure attribute mappings Enable both user and group provisioning. Under the provisioning configuration's attribute mappings, map only attributes Limrun supports. Remove mappings for enterprise-extension fields such as manager and department. | Entra source | SCIM user attribute | Configuration | |---|---|---| | mail | userName | Use as the matching attribute. Must equal the OIDC email claim. | | objectId | externalId | Stable directory identifier. | | displayName | displayName | Optional profile field. | | givenName | name.givenName | Optional profile field. | | surname | name.familyName | Optional profile field. | | mail | emails[type eq "work"].value | Same work email. | | Entra's account-enabled and assignment status mapping | active | Preserve the default lifecycle expression so deprovisioning sends false. | Use userPrincipalName for userName only when it equals the OIDC email. A guest's #EXT# principal name is not a work email. Every provisioned user's email domain must be verified in the Limrun organization. For groups, keep objectId mapped to externalId, displayName to displayName, and members to members. Use distinct group names for Entra matching, even though Limrun identifies groups by their SCIM IDs. See Microsoft's attribute mapping guide and Limrun's supported SCIM attributes and operations. Synchronize groups and activate access management 1. Assign pilot access groups under Limrun SCIM > Users and groups. Include an admin group and its direct user members. 2. Scope provisioning to Sync only assigned users and groups. Ensure those users can also sign in through Limrun SSO. See Microsoft's scoping guidance. 3. Start provisioning. Check Entra's provisioning logs and wait for both users and group memberships to appear in Limrun. A successful connection test alone does not synchronize the directory. 4. In Limrun, map the synchronized groups to Admin, Member, or Viewer. Confirm that every existing member who should retain access, including administrators, is active and belongs to a mapped group. 5. Select Activate SCIM. Test a provisioned user's SSO login, then test group removal, deactivation, and reactivation with a pilot account. Activation is permanent and can remove or demote the last admin. A user needs both an active SCIM record and membership in a mapped group. Direct application assignment without a mapped group does not grant Limrun access after activation. Nested groups are not supported; provision direct user memberships. For role precedence, recovery, and credential rotation, see SCIM provisioning. Troubleshoot "Your organization requires SSO". Use Continue with SSO and your work email. The separate Microsoft sign-in route cannot bypass the configured organization connection. "SSO not configured". Verify the email domain under Settings > Domain Verification and save the OIDC connection for the same organization. Microsoft rejects the redirect URI. Register https://api.limrun.com/authn/oidc/callback as a Web redirect URI on Limrun SSO. Do not use /authn/microsoft/callback for this setup. The ID token is missing email. Add the optional email ID-token claim and populate the user's email in Entra. Check that the value matches SCIM userName`. Microsoft rejects the client secret. Use the secret's Value, not its ID. If it expired, create a replacement and update the Limrun SSO connection. Microsoft asks for admin approval. Have an Entra administrator grant consent to the sign-in permissions on Limrun SSO, then retry with an assigned user. Provisioning returns 401 or 403. Check the SCIM base URL and Secret Token. After rotating the Limrun SCIM credential, replace the token in Entra; the previous credential stops working immediately. A provisioned user cannot enter Limrun. Confirm SCIM is activated, the user is active, and a synchronized group containing the user maps to a role. Also check assignment to Limrun SSO. Review both Entra provisioning logs and Limrun provisioning history for failed or pending changes. Users synchronize but groups do not. Enable group mappings, assign the groups to Limrun SCIM, and check the provisioning scope. Limrun calculates roles from SCIM group membership, not from OIDC group claims. --- ## Okta URL: https://docs.limrun.com/docs/reference/single-sign-on/okta Let your team sign in to Limrun with Okta through the Okta Integration Network app. Single sign-on with Okta The Okta app integration already contains the Limrun redirect and logout settings, so you install the app, verify your email domain, copy the Okta connection values into Limrun, and test sign-in. For other identity providers, see Single sign-on. Before you start You need: - Admin access to your Okta org. - Admin access to the Limrun organization. - Access to the DNS provider for the email domain your users sign in with. Supported features Limrun supports: - SP-initiated SSO - IdP-initiated SSO - Just-In-Time provisioning - Single Logout - Universal Logout through Global Token Revocation For Universal Logout, open the application settings in Okta and enable App logs out when Okta system or admin initiates logout. Verify your domain in Limrun Each SSO domain must be verified before users with that domain can sign in through the OIDC connection. Verification needs access to your DNS records; if that is not possible, contact Limrun support for manual verification. 1. Open console.limrun.com. 2. Go to Settings. 3. In Domain Verification, add the email domain that should use Okta SSO. 4. Create the TXT record shown in the verification dialog: | DNS field | Value | |---|---| | Type | TXT | | Host / Name | Copy from Limrun. | | Value | Copy from Limrun. | 5. After the DNS record is published, click Verify DNS in Limrun. Add Limrun in Okta 1. In the Okta Admin Console, go to Applications > Browse App Catalog. 2. Search for Limrun. 3. Add the Limrun app integration from the catalog. 4. Assign the users or groups that should be able to sign in to Limrun. Copy the connection values from Okta Open the Limrun app integration in Okta and go to the Sign On tab. Copy these values: | Limrun field | Okta value | |---|---| | Client ID | The app's client ID. | | Client Secret | The app's client secret. | | Issuer URL | The issuer from Okta's OpenID Provider Metadata. | To find the issuer, open the OpenID Provider Metadata link in Okta and copy the issuer value from the JSON document. For most Okta orgs, the issuer looks like: Save the Okta connection in Limrun 1. In Limrun, go to Settings > Single Sign-On. 2. Enter the Issuer URL, Client ID, and Client Secret from Okta. 3. Click Save. Sign in with Okta Users can start from Limrun: 1. Go to console.limrun.com/authn/login. 2. Click Continue with SSO. 3. Enter your work email address. 4. Complete authentication in Okta. Users can also start from the Limrun app tile in Okta. Troubleshooting Users see "SSO not configured". Confirm the user's email domain is added under Settings > Domain Verification and shows Verified. Okta says the client cannot use a custom authorization server. Use the Okta org issuer, for example https://your-org.okta.com, instead of https://your-org.okta.com/oauth2/default. A user authenticates in Okta but cannot enter Limrun. Confirm the user's email domain is verified in Limrun and assigned to the same organization as the OIDC connection. What the Okta app configures - The Limrun Okta app configures the redirect URI, initiate login URI, logout redirect URI, and Universal Logout endpoint. - A Limrun organization has one OIDC connection. - Without SCIM, a first Okta sign-in creates an account and joins the organization. With SCIM provisioning, provisioning creates the account before sign-in. New users can sign in after activation when they are active in the directory and belong to a group mapped to a Limrun role. --- ## SCIM provisioning URL: https://docs.limrun.com/docs/reference/single-sign-on/scim Control who can access your Limrun organization, and with which role, from your identity provider. SCIM provisioning SCIM manages who can access your organization. OIDC SSO handles sign-in. Configure SSO and verify your email domains before connecting provisioning. Connect your directory 1. Open Settings > Single Sign-On > Directory provisioning (SCIM) as an organization admin. 2. Generate a credential. Copy the SCIM base URL and the bearer credential into your identity provider's SCIM 2.0 configuration. The credential is shown once and grants provisioning access to this organization only. 3. Enable user creation, profile updates, deactivation, and group provisioning. Set SCIM userName to the same work email sent in the OIDC email claim. It must belong to a verified domain of this organization. 4. Assign users and push groups from the identity provider. Refresh the directory in Limrun to see them. 5. Map each group to Admin, Member, Viewer, or No access. 6. Select Save mappings to save your choices, or Activate SCIM to save and activate them. Activation makes the directory authoritative for organization membership. During setup, users and groups synchronize while existing members retain access. Newly provisioned users cannot sign in until activation. Activation is permanent; subsequent directory changes and saved mappings apply immediately. Directory syncs and credential rotation preserve unsaved role choices. If another admin changes mappings or activates SCIM, refresh before saving. Identity provider setup SSO and provisioning require separate configuration. Installing an SSO application does not start directory synchronization. | Identity provider | Provisioning configuration | |---|---| | Okta | Use a SCIM 2.0 application integration with HTTP bearer authentication. Enable user provisioning and Group Push. See Okta SSO for sign-in setup. | | Microsoft Entra ID | Create a non-gallery enterprise application and configure automatic provisioning with the SCIM base URL and credential as the Secret Token. Enable user and group mappings. Follow the Microsoft Entra ID guide. | Use the generated SCIM credential, not a Limrun organization API key or the OIDC application's client secret. For Entra's non-gallery setup, this credential works directly in the provisioning configuration; no OAuth token endpoint is needed. Check provisioning before activation 1. Test the connection in your identity provider. 2. Synchronize a pilot user and a group containing that user. Confirm the email matches their OIDC email claim. 3. In Limrun, confirm the group contains the expected user and map it to a role. Provisioning a user without a mapped group grants no access after activation. 4. Synchronize and map an admin group that includes the administrators who should retain access. Check existing members as well as new users before selecting Activate SCIM. After activation, test sign-in, group removal, deactivation, and reactivation with a pilot user. Check your identity provider's provisioning logs and Limrun's provisioning history after each change. Access changes take effect when Limrun receives the provisioning request, so allow for the provider's synchronization schedule. Limrun supports direct user membership in groups. Configure directory assignments and attribute mappings in your identity provider; Limrun does not create them for you. How access is calculated Once SCIM is activated, a user has access only when their SCIM record is active and at least one of their groups maps to a Limrun role. Provisioning creates their Limrun account immediately, or reuses their existing account by verified work email. Creating the account alone grants no access. Activation and group assignments create the organization membership before their first SSO sign-in. When several mapped groups contain a user, the highest role wins: Admin > Member > Viewer. Unmapped groups grant no access. Group mappings follow the group's stable SCIM ID, so a group rename preserves its mapping. A deleted and recreated group has a new ID and needs a new mapping. Groups can share a display name; mappings always use their IDs. Deactivating or deleting a user removes their organization membership. Deactivation takes precedence over any groups that still contain them. Removing a user from a mapped group recalculates access from their remaining groups. Reactivation restores access only if they still have an eligible mapped group. SCIM can remove or demote the last admin, including the person who activates SCIM. Keep an appropriate admin group mapped before applying the policy. If the last admin loses access, assign an active user to that mapped admin group in the identity provider to restore administration. Connecting SCIM disables domain auto-join, invitations, and manual membership changes, including during setup. Having an email in the organization's domain is not enough to join. SSO remains required for verified domains. Users denied admission see "Your organization manages access through SCIM" and should ask their administrator for an access-group assignment. Removal affects this organization's user access and user-approved MCP grants. It preserves the user's other organizations, shared organization API keys, and already-running instances. Manage the connection The provisioning screen shows synchronized users, their group-derived roles, and provisioning history. Errors appear in the history with the affected request and status. Rotate credential immediately replaces the old credential. Update the identity provider with the new value. Revoke credential stops provisioning requests without changing the access policy or current memberships. There is no switch back to manual membership management. SCIM requires the configured SSO connection and verified domains; removing SSO or changing those domains is blocked. Protocol support The base URL serves /Users, /Groups, /ServiceProviderConfig, /ResourceTypes, and /Schemas. User and group resources support create, read, replace, atomic PATCH, and delete. Lists support startIndex and count, up to 1,000 results per request. A zero count returns only the total. Supported equality filters are userName eq "alice@example.com" for users, displayName eq "Engineering" for groups, and externalId or id for either resource. User names and group display names match without regard to case. Use attributes or excludedAttributes to select or exclude fields, including sub-attributes such as name.givenName. These parameters cannot be combined. Unsupported filters return a SCIM invalidFilter error. Requests are limited to 1 MiB. PATCH requests support up to 100 operations and apply atomically. Value filters in PATCH paths support compound expressions, such as emails[type eq "work" and primary eq true].value. Requests exceeding the filter evaluation or output limits return HTTP 413. Send active as a JSON boolean. To remove a single group member, use a filtered PATCH path such as members[value eq "USER_ID"]. Removing members without a filter clears all members. Empty members and groups attributes may be omitted from responses. Server-assigned attributes such as id are ignored in create and replace requests. Groups contain direct user members. Nested groups, bulk requests, sorting, password provisioning, enterprise extensions, and ETags are not supported. Flatten nested directory membership before provisioning. The service advertises its capabilities and supported core profile attributes through SCIM discovery endpoints. --- ## Appium URL: https://docs.limrun.com/docs/testing/appium Run your existing Appium suite against cloud iOS simulators and Android emulators from any host, including a Linux CI runner. Run Appium tests Your test code stays the same; only the driver setup and the capabilities change. iOS needs a Limrun fork of the XCUITest driver, while Android works with any upstream Appium driver over an ADB tunnel. Before you start - The lim CLI and LIMAPIKEY set up as in the Quickstart. - Node 20 or newer to run the Appium server. - An Appium test that already runs against a local device. 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 driver (UiAutomator2, Espresso, Flutter) 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 instance operations (create, list, get, delete) but has no device client, so the WDA health check below is in TypeScript and Go. The Appium session itself runs in whatever language your test code is in. reuseIfExists returns an existing instance only when its labels match exactly, so give each CI job or dev session its own label set. See Concepts for how reuse and placement interact. When the call resolves, three fields on the returned instance object feed into Appium over the next few sections: - instance.status.apiUrl, the instance API URL. The Limrun device client talks to it, and the Appium driver uses it for simctl calls and file uploads. - instance.status.token, the instance 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: 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 instance token. If it doesn't answer cleanly, relaunch WDA with simctl through the Limrun device client: iOS capabilities Pick your Appium client below. The capability keys are the same in every client; only the surrounding code differs. The Limrun-specific capabilities: | Capability | Required | What it does | |---|---|---| | appium:limInstanceApiUrl | yes | The instance API URL (instance.status.apiUrl). The fork uses it for simctl, file uploads, and Safari socket tunneling. | | appium:limInstanceToken | yes | The instance 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 instance 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 driver. UiAutomator2, Espresso, and Flutter drivers all work unchanged. Open the ADB tunnel Create an emulator and open an ADB tunnel to it. The CLI does both in one step; the SDKs open the tunnel after the create call returns: 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 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). The job above creates instances from your test code, so label them there, for example metadata: { labels: { session: process.env.GITHUBRUNID! } }, and delete them in your suite's teardown (such as afterAll), including on failure. Inactivity timeouts handle the rest if a job crashes. Listing and deleting by label is covered in Run a simulator and Run an emulator. Troubleshooting WDA isn't responding. Request ${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. Raising the instance's inactivityTimeout also helps for long suites. Next steps Create, configure, and drive iOS simulators: the control surface Appium sits on. ADB tunnel, instance configuration, and the Android device client. Drive Chrome on Android emulators over CDP. Store the WebDriverAgent bundle and your builds once instead of downloading them on every job. --- ## Maestro URL: https://docs.limrun.com/docs/testing/maestro Run your Maestro flows against a cloud iOS simulator from any machine, including a Linux CI runner. Run Maestro flows lim ios maestro wraps the stock Maestro CLI. Your flows and Maestro itself stay unmodified: lim points Maestro's iOS driver at the remote simulator, and the flow runs as if the simulator were local. Maestro on Limrun covers iOS simulator instances. For Android UI automation, use Appium over the ADB tunnel. Before you start - The lim CLI (0.22.0 or newer) and LIMAPIKEY set up as in the Quickstart. - The Maestro CLI on your PATH, which needs a Java runtime. See Installing Maestro. Maestro 2.5.x and 2.6+ both work; lim adapts to the installed version. - A Maestro flow that already runs against a local simulator. - The app under test as a simulator build in an archive (MyApp.app.zip or .tar.gz), for example from Build with Xcode. How the integration works Maestro drives iOS through a small XCTest runner app that serves HTTP inside the simulator, plus xcrun simctl calls for the app lifecycle. lim ios maestro provides both against a remote instance: - It installs Limrun's patched build of the Maestro runner on the instance the first time you use it. Pre-install the runner at create time to skip that step. - It runs your local maestro test with the driver's HTTP traffic routed to the runner over an authenticated HTTPS tunnel, and answers the simctl calls through the Limrun API. The tunnel authenticates with the instance token, the same mechanism the Appium integration uses for WebDriverAgent. Maestro's exit codes, console output, and report flags work as they do locally, so existing CI wiring carries over. Only the test subcommand is wrapped; maestro studio, record, and hierarchy are not available against remote instances. Run a flow Create an instance with the runner and the app under test pre-installed, then run your flow: Without the --install-asset line, lim ios maestro installs the runner on first use. Either way, the first run on an instance takes a few extra seconds while the runner launches. If you run the same app often, upload it as an asset once instead of re-uploading with --install on every create. lim ios maestro targets the last iOS instance the CLI created for this workspace (your git repository or worktree). In scripts and CI, pass --id before test: Extra Maestro flags go after --: Do not pass --platform, --device, --udid, --no-reinstall-driver, or --driver-host-port. lim sets those itself and rejects duplicates. The runnable example, including the same wiring from the TypeScript SDK with prepareMaestroRun, is at typescript-sdk/examples/maestro-ios. Run an Expo Go flow Pre-install Expo Go alongside the runner and open your project URL from the flow. Environment variables must start with MAESTRO_ to be visible inside flows: The flow opens the project and waits out the first bundle load: To serve the project from your machine, see Connect to local services. Differences from local Maestro lim answers a fixed subset of simctl for Maestro through a shim, and rejects commands that take or return host filesystem paths. In practice: - The startRecording and stopRecording flow commands are not supported. Record around the run instead: lim ios record start before the flow, lim ios record stop -o video.mp4 after. See Run a simulator. - addMedia and clearKeychain are not supported. - takeScreenshot works and saves the PNG locally into the working directory (or --test-output-dir), like stock Maestro. - HTTP calls from runScript and evalScript must use https:// URLs. Maestro's plain-HTTP traffic is routed to the remote simulator's driver, so https calls go out directly while plain http calls are refused. - When a flow runs against an app that is already open (Expo Go especially), start with - stopApp before openLink or launchApp. An app that is mid-foreground can drop deep links, and a stale screen fails early assertions. Run from a Linux CI runner No macOS host is needed; the Mac lives on Limrun's side. A standard ubuntu-latest GitHub Actions job is enough: The job labels the instance with the run ID and deletes it at the end, including on failure. Inactivity timeouts clean up after jobs that crash. Troubleshooting The Maestro CLI is not on PATH, or its Java runtime is missing. Install it per Installing Maestro and re-run; lim detects the version automatically. The full message ends with "Only the test subcommand is supported." lim ios maestro wraps maestro test only. Run maestro studio and the other subcommands against a local simulator, or drive the remote instance directly with the lim ios commands. Remove --platform, --device or --udid, --no-reinstall-driver, and --driver-host-port from your Maestro arguments. lim injects them, and duplicate flags make Maestro's parser fail. The runner was installed or launched but did not answer within 15 seconds. Retry once. If it persists, check that the instance is ready with lim ios get, and recreate it with the runner pre-installed through --install-asset. Your flow used a command outside the supported subset (see Differences from local Maestro). Recording has a lim ios record equivalent. Maestro prints its debug output directory on failure. For the live screen, lim ios element-tree shows the accessibility tree of the remote simulator and lim ios screenshot captures it. See Run a simulator. Next steps Create, configure, and drive iOS simulators directly. The WebDriver alternative, including Android automation. Upload the app under test once and install it on every create. Native unit and UI tests with lim xcode test. --- ## Playwright URL: https://docs.limrun.com/docs/testing/playwright Run your Playwright tests against Chrome on a cloud Android emulator from any machine. Run Playwright tests Your existing Playwright test code keeps working against Chrome on the emulator. The CDP connection terminates on the emulator's host, so tests stay responsive even when your test runner is in another region. Before you start - LIMAPIKEY set up as in the Quickstart. - Node 20 or newer. - The playwright package in your project, at a version the Playwright service runs (see Match the Playwright version). The Android device API ships in the regular playwright package under an internal export (_android). The TypeScript sample below selects the 1.60.0 service. Instances created from the Python or Go SDK run the default 1.56.1 service, so install playwright@1.56.1 for those. Choose how Playwright connects 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 service (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 Playwright service; the tunnel path is covered at the end. Create the instance Two things change relative to a plain Android create call. First, turn on the Playwright service (spec.sandbox.playwrightAndroid) so the instance exposes a CDP WebSocket, and pick its Playwright version. 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 and Go SDKs cover the create call, but only the TypeScript SDK exposes the version field. From Python or Go, the service runs its default version; send version through the REST API if you need another one. Playwright's Android device API ships only in the Node binding, so the rest of the page stays in TypeScript. Match the Playwright version spec.sandbox.playwrightAndroid.version selects the Playwright server the service runs: '1.56.1-lim.1' (the default) or '1.60.0-lim.1'. Install the matching client in your project, playwright@1.56.1 or playwright@1.60.0. A client on another version fails the WebSocket handshake with a "Playwright version mismatch" error. reuseIfExists returns an existing instance only when its labels match exactly, so give each CI job or dev session its own label set. See Concepts for how reuse and placement interact. 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 instance token. Authenticates the URL above. It's instance.status.token. Connect Playwright Only the Node binding of Playwright exposes the Android device API; the other Playwright language bindings do not. 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 instance 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. Connect through a local ADB tunnel instead If you'd rather not enable the Playwright service, 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 Your playwright package and the service's version differ. Install the client that matches spec.sandbox.playwrightAndroid.version (1.56.1 when you omit it), or recreate the instance with the version your client uses. See Match the Playwright version. The guard in the connect snippet above throws this when 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 and native apps, Android native apps. Instance lifecycle, the ADB tunnel, and the Android device client. App assets for native testing, installed at boot. --- ## XCTest URL: https://docs.limrun.com/docs/testing/xctest Gate CI on your unit and UI tests by running them on a cloud iOS simulator, with a result line per test case. Run XCTest suites lim xcode test builds your scheme's test targets on a remote Xcode sandbox, runs them on an attached cloud iOS simulator (unit and UI targets alike), and streams one line per test case as it finishes. It exits non-zero when any test fails, so it drops straight into CI. Everything about syncing from Build with Xcode applies here too: XcodeGen projects, ignore and include patterns, additional files, and the sync cache. Run the suite From the project directory, run the tests of the default scheme, or name the scheme explicitly: The command reuses your last simulator-backed Xcode target, or creates and attaches the sandbox and simulator it needs, so repeat runs skip provisioning and start with warm build state. The scheme must have a test action configured; shared schemes exported by Xcode have one whenever the project has test targets. --xcode-version 27 builds the test targets with another installed Xcode (27 selects that major's newest GA release, 27.1 selects that exact version). The simulator keeps the fleet's default runtime, so the run warns that runtime-dependent failures are possible and then proceeds. See Xcode and tool versions. The output streams the build log first, then one line per case and a closing summary: Swift test classes are reported module-qualified (Module.Class), Objective-C classes bare. Select tests Pass xcodebuild's identifier format Target[/Class[/method]]. Repeat a flag for several entries; --only-testing and --skip-testing are mutually exclusive: A bare target name selects or skips that whole target. Targets named by no --only-testing entry are skipped entirely. An entry naming a target the build did not produce fails the run instead of silently running everything. Launch settings and attachments Limrun preserves the launch arguments and environment recorded in the .xctestrun manifest, and keeps test-host and UI app settings separate: CommandLineArguments and EnvironmentVariables configure the test runner, while UITargetAppCommandLineArguments and UITargetAppEnvironmentVariables configure the application under UI test. Arguments containing spaces stay single arguments. Every enabled test plan configuration runs, including configurations that repeat the same test targets. XCTest activities are enabled, so tests can use XCTContext.runActivity and add XCTAttachment objects. The streamed results contain case outcomes and summaries; they do not include attachment downloads or an .xcresult bundle. Get JSON results --json streams the raw per-case events as NDJSON and ends with an exit record, for piping into your own tooling: Each line is one event: A missing summary event means the run died partway. planFinished: false means the suite started but did not complete its plan. Build without running --build-only compiles the test targets on a plain sandbox without acquiring a simulator. The products stay on the sandbox for a later run: Run tests from the TypeScript SDK The TypeScript SDK runs tests through xcodebuild with action: 'build-for-testing'. It does not create a simulator: the tests run only when a simulator is attached to the Xcode sandbox, and without one the call only compiles them. Attach one first, as in Install on a simulator. Per-case events stream through onXctestEvent, and the accumulated cases and summary land on the result: onlyTesting and skipTesting go on the same settings object as scheme. runTests: false compiles the test targets without running them, like --build-only. xcode is the Xcode sandbox client from Build with Xcode. Run in CI - The exit code is the gate: 0 when every selected test passes, non-zero otherwise, so lim xcode test . works as a CI step with no output parsing. - For reproducible runs on fresh instances, pass --inactivity-timeout 30m. The run then creates its own sandbox and simulator instead of reusing yours, and they delete themselves once idle. This flag cannot be combined with --id. GitHub Actions recipes shows how to set up the rest of a Linux CI job: install the CLI and pass LIMAPIKEY from a secret. Next steps Sync rules, schemes, and build flags shared with lim xcode test. Run YAML UI flows against the same cloud simulators. Pair the test run with a preview link on every pull request. ---