CLI for coding agents
Set up the lim CLI and Limrun skill where your coding agent runs. Then ask the agent to make a change and verify it on a remote iOS simulator.
The skill gives the agent the Limrun workflow. Use the command reference below when you want to see the commands it will run.
One-time setup
1. Install the CLI
# Pick your package manager
npm install --global lim
pnpm add --global lim
bun add --global limNeeds Node.js 20 LTS or newer.
2. Set your API key
Set LIM_API_KEY in the shell your agent uses. The CLI also picks up .env files in the working directory.
export LIM_API_KEY=lim_...The API key can create, list, and delete every instance in your organization. If the agent runs in a sandbox that shouldn't hold that much power, skip this step and hand it a single instance instead.
3. Install the Limrun skill
From your project root, install the skill:
lim skills installThe command fetches the latest skills from limrun-inc/skills, then prompts for the target agent, skill, and install scope. Keep the catalog defaults unless you are setting up a different workflow.
4. Start a coding session
Open the same project in the agent you installed the skill for, then ask for the product change you want. Include that it should build and run the app on Limrun:
Update the onboarding screen, build the app with Limrun, run it on the remote simulator, and share the Signed Stream URL so I can review it.What the agent does
The loop the skill walks the agent through:
- Create or reuse an instance. One
lim ios create --xcodeboots a simulator and an attached Mac sandbox. Labels let re-runs land on the same instance. - Build the app.
lim xcode build .syncs the source to the sandbox, runsxcodebuildremotely, streams the logs back, and installs the build on the simulator on success. - Read the screen, then act. Before every tap, the agent reads
lim ios element-treeand picks elements by accessibility ID. No flaky pixel coordinates. - Record video for changes that involve motion.
- Upload the build and share a preview link so reviewers open the app in any browser.
- Delete the instance when the work is done.
The rest of this page is the command reference behind each step.
Create an instance
lim ios create --xcode --reuse-if-exists--xcode attaches a Mac sandbox to the simulator (required for lim xcode build). --reuse-if-exists returns an existing instance in the same region instead of provisioning a new one, so retries land back on the same warm instance. Scope reuse to a specific task by adding a label (--label issue=<ID>); without one, every reuse call in your org matches the same unlabeled instance.
The output includes the instance ID, the Signed Stream URL, and the Xcode sandbox URL. Open the Signed Stream URL in any browser to watch the simulator live; the token is in the URL, so no login is required. Share the Signed Stream URL with the user at the start of a task.
Useful flags
| Flag | What it does |
|---|---|
--xcode | Attach a Mac sandbox. Required for lim xcode build. |
--reuse-if-exists | Return an existing instance with the same labels instead of creating a new one. |
--label key=value | Repeatable. Used by --reuse-if-exists and for cleanup. |
--region us-west | Pin to a region. |
--inactivity-timeout 10m | Auto-delete after this much idle time. 1m, 10m, 3h. Org default applies if you skip it. |
--hard-timeout 3h | Force-delete after this much wall time. 0 (default) means no cap. |
--install ./build.app | Local app to upload and install at boot. Repeatable. |
--install-asset <name> | Pre-installed asset from Asset Storage. Repeatable. |
--force-bundle-id com.example.app | After this app first enters the foreground, bring it back whenever it is closed or backgrounded. Does not launch it at startup. |
--model iphone|ipad|watch | Device model. Defaults to iphone. |
--rm | Delete the instance when the CLI process exits. Handy for one-shot runs. |
Build the app
lim xcode build .This is the only build command. Don't use local xcodebuild. The CLI syncs your working directory to the Mac sandbox (only the changed bytes), runs xcodebuild server-side, streams the logs back, and installs the build on the attached simulator on success.
For workspaces or multi-scheme projects:
lim xcode build . --workspace MyApp.xcworkspace --scheme MyApp
Build flags
| Flag | What it does |
|---|---|
--scheme MyApp | Xcode scheme name. |
--workspace path.xcworkspace | Use a workspace file. |
--project path.xcodeproj | Use a project file (alternative to --workspace). |
--xcodegen-spec path --xcodegen-project dir --xcodegen-project-root dir | Pin XcodeGen inputs when the spec isn't a root-level project.yml. They mirror xcodegen generate flags, with paths relative to the synced root. Passing any of them forces regeneration. |
--sdk iphonesimulator|iphoneos|watchsimulator|watchos | Target SDK. |
--upload <asset_name> | Save the build artifact to Asset Storage under this name. Used for preview links. |
--signed-upload-url <url> | Upload to your own pre-signed URL (S3, GCS, etc). |
--certificate-p12 path.p12 --certificate-password ... --provisioning-profile path.mobileprovision | Sign for a real-device build. --provisioning-profile is repeatable: pass one profile per bundle the app embeds (the app plus each extension or watch app). |
--signing-method app-store-connect|release-testing|debugging --team-id ... | Use Apple cloud signing instead of a p12 and provisioning profiles. Also requires --asc-key-id, --asc-issuer-id, and --asc-key. Distribution methods require access to cloud-managed distribution certificates. |
--upload-to-appstore --asc-key-id ... --asc-issuer-id ... --asc-key path.p8 | Upload the signed IPA to App Store Connect after the build, making it available for TestFlight or App Store distribution. Requires manual or cloud signing. |
--asc-wait-timeout N | Seconds to watch App Store Connect's processing verdict (default 0: return once the upload commits, max 1800). |
--auto-build-number | Set the build number to one more than the highest already in App Store Connect, or 1 for a new app. Requires --upload-to-appstore and Xcode-standard versioning. |
--webhook-url URL --webhook-header NAME=VALUE | POST the terminal build result and persisted-log URL to an HTTPS callback. Repeat the header flag as needed. |
--detach | Return once limbuild accepts the build instead of streaming until completion. Requires --webhook-url. |
--inactivity-timeout 3s | Create a fresh instance for this build and reap it shortly after its last activity. Cannot be combined with --id. |
--build-setting KEY=VALUE | Pass an allowlisted build setting (any APP_CONFIG_* key or SWIFT_ACTIVE_COMPILATION_CONDITIONS). Repeatable. |
--additional-file local=remote | Sync an extra file (like ~/.netrc) into the sandbox on every build. Repeatable. |
--ignore 'regex' | Skip paths during sync. Repeatable. |
--include 'regex' | Force-sync paths your .gitignore excludes, like generated sources the build needs. Repeatable. |
--basis-cache-dir path | Tune the local sync cache. |
The sync skips .git, .DS_Store, the basis cache, and anything in your .gitignore files. .xcconfig files are always synced. If your .gitignore doesn't cover DerivedData/ or xcuserdata/, add them with --ignore.
If the project's .xcodeproj is gitignored and a project.yml exists (the standard XcodeGen setup), the sandbox generates the project with XcodeGen before building, no flags needed; see Build with remote Xcode.
Read the screen, then act
Always read state before the next action. The element tree shows every label, accessibility ID, type, and frame on the screen. It's the source of truth, not a screenshot.
lim ios element-treeThe tree can be big. Pipe through grep or jq so it doesn't fill your agent's context window:
lim ios element-tree --json | jq '.[] | select(.AXLabel | contains("Continue"))'For a visual check, take a screenshot:
lim ios screenshot ./out.pngTap, type, scroll
Find elements by accessibility ID first, then label, then coordinates as a last resort. The first two survive UI changes; coordinates don't.
lim ios tap-element --ax-unique-id startButton
lim ios tap-element --ax-label "Save"
lim ios tap-element --type Button --ax-label "Done"
lim ios tap 201 450tap-element accepts --ax-unique-id, --ax-label, --ax-label-contains, --type, --title, --title-contains, and --ax-value. Combine them to narrow the match. It taps with a real touch and scrolls elements the accessibility tree can see into view automatically. A selector that matches nothing fails in about a second; iOS creates list rows lazily, so below-the-fold rows are often absent from the tree. Pass --scroll-search to page the screen and retry until the row materializes (up to ~10s). --activate ax performs an accessibility press instead.
Text, keys, scrolling, deep links:
lim ios type "hello world" --enter # real key events into the focused field; errors if none
lim ios type "hi" --no-require-focus # skip the focus check when you focused a field by coordinates
lim ios set-text "P@ssw0rd!" --focused # exact value via accessibility, no keyboard behavior
lim ios set-text "42" --ax-label Count # same, by selector
lim ios press-key @ # shifted symbols work directly
lim ios press-key enter --modifier shift
lim ios scroll down --amount 300
lim ios scroll up --amount 300 --momentum 0.4 --coordinate 200,400
lim ios swipe --from 200,300 --to 200,600 # explicit drag, e.g. pull-to-refresh
lim ios open-url "https://apple.com"Run several actions in one shot
When a sequence shouldn't have round-trip latency between steps, use lim ios perform. The server runs the whole batch and stops on the first failure.
lim ios perform \
--action type=tap,x=100,y=200 \
--action "type=typeText,text=Hello World"
lim ios perform \
--action type=wait,durationMs=1000 \
--action type=pressKey,key=enterOr from a file:
lim ios perform --file ./actions.yaml- type: tapElement
selector: { AXLabel: "Continue" }
- type: wait
durationMs: 500
- type: typeText
text: "hello"
pressEnter: trueSupported types: tap, tapElement, incrementElement, decrementElement, setElementValue, typeText, pressKey, scroll, toggleKeyboard, openUrl, setOrientation, wait, touchDown, touchMove, touchUp, keyDown, keyUp, buttonDown, buttonUp.
Hardware button names for buttonDown and buttonUp: home, lock, side, applePay, softwareKeyboard.
Apps and logs
lim ios list-apps # bundle IDs of every installed app
lim ios launch-app com.example.MyApp
lim ios launch-app com.example.MyApp --mode RelaunchIfRunning
lim ios terminate-app com.example.MyApp
lim ios install-app ./MyApp.app.zip # local zipped .app folder, or
lim ios install-app https://... # remote URL (downloaded server-side)Stream live logs or tail the last N lines:
lim ios app-log com.example.MyApp --follow
lim ios app-log com.example.MyApp --tail 200Record a video
For anything involving motion (animations, gestures, gameplay), record a video instead of taking screenshots. Always attach it to the PR.
lim ios record start
# ... drive the UI ...
lim ios record stop -o /tmp/demo.mp4--quality 5 is the default; raise it up to 10 for higher fidelity at the cost of file size. Pass --presigned-url instead of -o to upload the recording straight to your own bucket.
Share a preview link
When the work is ready for review, build with --upload so the artifact lands in Asset Storage, then post the preview URL.
ASSET_NAME="${PR_NUMBER:-$(date +%s)}.zip"
lim xcode build . --upload "${ASSET_NAME}"Then share:
https://console.limrun.com/preview?asset=${ASSET_NAME}&platform=iosThe reviewer opens it in any browser. No install, no Mac.
Delete the instance
lim ios deleteWith no ID, this deletes the last instance the CLI created (tracked in ~/.lim/last-instances.json). If an agent created several instances in one session, list them by label first:
lim ios list --label-selector "agent=cursor,issue=LIM-34"Hand one instance to a sandboxed agent
LIM_API_KEY is an org-wide credential: it can create, list, and delete every instance you have. An agent in an untrusted or shared sandbox shouldn't hold it. Instead, create the instance outside the sandbox and hand the agent only that instance's own credentials. The CLI drives a pinned instance without any API key.
1. Create the instance and capture its credentials
Run this where the API key lives (your machine, the orchestrator, CI). If the orchestrator is a backend service, the SDK makes the same call:
lim ios create --json > instance.json
jq -r .status.apiUrl instance.json # the instance's API URL
jq -r .status.token instance.json # the instance's tokenimport Limrun from '@limrun/api';
const lim = new Limrun({ apiKey: process.env.LIM_API_KEY });
const instance = await lim.iosInstances.create({ wait: true });
// Hand instance.status.apiUrl and instance.status.token to the sandbox.Each resource is created individually as the agent needs it: lim android create --no-connect --json (--no-connect skips the local ADB tunnel, which would mix its output into the JSON), lim xcode create --json for a build sandbox, lim gradle create --json for Android builds. The SDK equivalents are lim.androidInstances.create, lim.xcodeInstances.create, and lim.gradleInstances.create, all with { wait: true } to return once the instance is ready. Every one returns its own status.apiUrl and status.token.
2. Point the sandboxed CLI at the instance
Export the pair inside the sandbox; every lim ios command picks them up directly, nothing is persisted:
export LIM_IOS_INSTANCE_URL=https://.../v1/ios_.../api
export LIM_IOS_INSTANCE_TOKEN=lim_st_...
lim ios element-tree # works with no LIM_API_KEYOr pin it once per workspace with set-instance, which stores it in ~/.lim/last-instances.json:
lim ios set-instance --api-url https://.../v1/ios_.../api --token lim_st_...Both exist for every instance type: the env pairs are LIM_<TYPE>_INSTANCE_URL and LIM_<TYPE>_INSTANCE_TOKEN with IOS, ANDROID, XCODE, or GRADLE, and the commands are lim {ios,android,xcode,gradle} set-instance. Android additionally takes LIM_ANDROID_INSTANCE_ADB_URL (status.adbWebSocketUrl, or the --adb-websocket-url flag) if the agent should run lim android connect.
When both are present, an explicit --id wins over the env pair, which wins over the pinned instance.
3. Builds work the same way
lim xcode build targets an Xcode sandbox, its own resource with its own credentials. Create it where the key lives and hand over its pair like any other instance:
lim xcode create --json > xcode.jsonThen in the sandbox:
export LIM_XCODE_INSTANCE_URL=$(jq -r .status.apiUrl xcode.json)
export LIM_XCODE_INSTANCE_TOKEN=$(jq -r .status.token xcode.json)
lim xcode build . # remote build, still no API keylim gradle build follows the same pattern with lim gradle create --json and the LIM_GRADLE_INSTANCE_URL/LIM_GRADLE_INSTANCE_TOKEN pair.
4. Share files through signed asset URLs
Asset Storage lives on the management API, so the sandbox can't mint asset URLs itself. Create the asset outside and hand the sandbox its signed URLs; they carry their own authorization.
To let the agent ship a build artifact, mint the asset where the key lives and pass its upload URL in:
: > agent-build.zip # empty placeholder; the agent's upload replaces it
lim asset push ./agent-build.zip
lim asset list --name agent-build.zip --upload-url --json | jq -r '.[0].signedUploadUrl'const asset = await lim.assets.getOrCreate({ name: 'agent-build.zip' });
// Hand asset.signedUploadUrl to the sandbox. Nothing has to be uploaded yet;
// the same call also returns asset.signedDownloadUrl for later.In the sandbox, the build uploads straight to that URL without touching the management API:
lim xcode build . --signed-upload-url "$UPLOAD_URL"To hand the agent a file (a prebuilt app, a fixture), push it outside with lim asset push ./MyApp.app.zip, grab the URL with lim asset list --name MyApp.app.zip --download-url --json, and pass it in. The agent installs it keyless; the instance downloads the URL server-side:
lim ios install-app "$DOWNLOAD_URL"What still needs the key
Anything on the management API (creating, listing, and deleting instances, and minting asset URLs like lim xcode build --upload does) fails with an authentication error in the sandbox. That's 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.
Watch out for these
element-treeis big. Pipe throughgreporjqso it doesn't fill your agent's context window.- Build errors are the agent's job. If
lim xcode buildfails, the agent reads the output, fixes the code, and rebuilds. It doesn't push the failure back to the user. - Don't know the bundle ID? Check the Xcode project, or run
lim ios list-appsafter a successful build. lim ios deletedefaults to the last instance. If you created more than one in a session, runlim ios listfirst.
Next steps
Was this guide helpful?