Llim.run

Run an iOS Simulator

Your iOS instance is ready. For provisioning, see the Quickstart; for builds, see Build with remote Xcode.

Connect to your instance

Open a WebSocket client to drive the instance.

import { Ios } from '@limrun/api';

const client = await Ios.createInstanceClient({
  apiUrl: instance.status.apiUrl!,
  token: instance.status.token,
  logLevel: 'info',         // 'none' | 'error' | 'warn' | 'info' | 'debug'
});

console.log('UDID:', client.deviceInfo.udid);
console.log('Screen:', client.deviceInfo.screenWidth, client.deviceInfo.screenHeight);
import (
    "context"
    limrun "github.com/limrun-inc/go-sdk"
    "github.com/limrun-inc/go-sdk/option"
    iosws "github.com/limrun-inc/go-sdk/websocket/ios"
)

lim := limrun.NewClient(option.WithAPIKey(os.Getenv("LIM_API_KEY")))
inst, _ := lim.IosInstances.New(ctx, limrun.IosInstanceNewParams{
    Wait:          param.NewOpt(true),
    ReuseIfExists: param.NewOpt(true),
})

client, err := iosws.NewClient(inst.Status.APIURL, inst.Status.Token)
if err != nil { panic(err) }
defer client.Close()

Or from the CLI:

# The CLI maintains its own connection per command. No explicit client step.
lim ios screenshot ./out.png

The TypeScript and Go clients initialize differently:

Where the URLs come from

The instance status carries everything you need:

Status fieldUse
apiUrlHTTP base for the device daemon. Pass to the control client.
tokenPer-instance bearer for apiUrl, endpointWebSocketUrl, and sandbox.xcode.url. Embedded in signedStreamUrl.
endpointWebSocketUrlWebSocket endpoint for the <RemoteControl /> web component (pass with ?token=).
signedStreamUrlConsole-hosted watch URL with the token embedded; open in a browser without your API key.
mcpUrlPer-instance MCP HTTP endpoint. Auth with the per-instance status.token, not your org API key. See MCP.
sandbox.xcode.urlXcode sandbox API URL (only when spec.sandbox.xcode.enabled: true).
targetHttpPortUrlPrefixReverse-proxy prefix for tunneled HTTP ports on the device.
stateunknowncreatingassignedreadyterminated.

Read the screen

Before any action, read what's actually on the screen. The element tree is the source of truth for what's interactable; the screenshot is the fastest visual sanity check.

const tree = await client.elementTree();
// tree: ElementTreeNode[] (recursive accessibility hierarchy)
// fields per node: AXLabel, AXUniqueId, AXValue, role, type, title, traits, frame, children

const shot = await client.screenshot();
// { base64: string, width: number, height: number }
treeJSON, err := client.ElementTree(ctx, nil)   // raw JSON; parse with encoding/json
shot, err := client.Screenshot(ctx)             // shot.Base64, shot.Width, shot.Height

Or from the CLI:

lim ios element-tree
lim ios element-tree --json | jq '.[] | select(.AXLabel == "Continue")'
lim ios screenshot ./out.png

Screenshot dimensions are in points, the same coordinate space as tap(x, y). If you want to tap based on a coordinate in a screenshot's pixel space (e.g. when the screenshot was scaled), use tapWithScreenSize:

await client.tapWithScreenSize(x, y, screenshotWidth, screenshotHeight);

Tap

Prefer accessibility selectors. They survive layout changes; coordinates don't.

// By accessibility identifier (most stable)
await client.tapElement({ AXUniqueId: 'startButton' });

// By label
await client.tapElement({ AXLabel: 'Save' });

// By label contains (case-insensitive)
await client.tapElement({ AXLabelContains: 'continue' });

// By type + label
await client.tapElement({ type: 'Button', AXLabel: 'Done' });

// By coordinates (last resort)
await client.tap(201, 450);
// Selector field names differ slightly from TypeScript:
//   AccessibilityID, Label, LabelContains, ElementType, Title, TitleContains, Value
_, err := client.TapElement(ctx, iosws.AccessibilitySelector{
    AccessibilityID: "startButton",
})
_, err = client.TapElement(ctx, iosws.AccessibilitySelector{
    Label: "Save",
})
_, err = client.TapElement(ctx, iosws.AccessibilitySelector{
    ElementType: "Button",
    Label:       "Done",
})
err = client.Tap(ctx, 201, 450)

Or from the CLI:

lim ios tap-element --ax-unique-id startButton
lim ios tap-element --ax-label "Save"
lim ios tap-element --ax-label-contains continue
lim ios tap-element --type Button --ax-label "Done"
lim ios tap 201 450

AccessibilitySelector accepts AXUniqueId, AXLabel, AXLabelContains, type, title, titleContains, and AXValue (TypeScript field names; Go uses AccessibilityID, Label, LabelContains, ElementType, Title, TitleContains, and Value for the same fields). All non-empty fields must match (AND, not OR).

Steppers, sliders, and text values

For controls that aren't tapped but adjusted, use the element-targeted variants:

await client.incrementElement({ AXLabel: 'Volume' });
await client.decrementElement({ AXLabel: 'Volume' });
await client.setElementValue('42', { AXLabel: 'Count' });   // faster than typing char-by-char

Type and press keys

Type into the currently focused field, press individual keys with optional modifiers, or toggle the on-screen keyboard. toggleKeyboard is the equivalent of pressing ⌘K in the simulator.

await client.typeText('hello world');                   // types into focused field
await client.typeText('[email protected]', true);       // pressEnter = true
await client.pressKey('enter');
await client.pressKey('a', ['shift']);                   // modifiers: shift, command, control, alt
await client.toggleKeyboard();
err := client.TypeText(ctx, "hello world", false)
err = client.TypeText(ctx, "[email protected]", true) // pressEnter
err = client.PressKey(ctx, "enter")
err = client.PressKey(ctx, "a", "shift")
// toggleKeyboard is TypeScript-only today.

Or from the CLI:

lim ios type "hello world" --enter
lim ios press-key enter --modifier shift
lim ios toggle-keyboard

Scroll and orientation

scroll is a finger-swipe gesture. pixels is the gesture distance in points; coordinate (TS only) defaults to the screen center; momentum (0.0-1.0) controls fling decay. setOrientation flips the device between portrait and landscape.

await client.scroll('down', 300);
await client.scroll('up',   300, { coordinate: [200, 400], momentum: 0.4 });

await client.setOrientation('Portrait');
await client.setOrientation('Landscape');

Or from the CLI:

lim ios scroll down --amount 300

openUrl works for both web URLs and registered deep-link schemes.

await client.openUrl('https://apple.com');               // opens in Safari
await client.openUrl('myapp://orders/42');               // deep link into your app

Or from the CLI:

lim ios open-url "myapp://orders/42"

Run a batch of actions

When a sequence shouldn't have a round-trip between each step, use performActions. The server runs the whole batch inside the pod, stops on the first failure, and reports per-action results. This is the pattern agents reach for once they're driving anything more than a single tap.

const result = await client.performActions(
  [
    { type: 'tapElement', selector: { AXLabel: 'Continue' } },
    { type: 'wait', durationMs: 500 },
    { type: 'typeText', text: 'hello', pressEnter: true },
    { type: 'scroll', direction: 'down', pixels: 300 },
    { type: 'pressKey', key: 'enter' },
  ],
  { timeoutMs: 10_000 },
);
// result.results is per-action

Or from the CLI:

lim ios perform \
  --action 'type=tapElement,selector={"AXLabel":"Continue"}' \
  --action type=wait,durationMs=500 \
  --action "type=typeText,text=hello,pressEnter=true"

Action type values:

Hardware button values: home, lock, side, applePay, softwareKeyboard.

Multi-touch and custom gestures

The raw HID primitives let you build gestures with precise timing. Pair touchDown + touchMove + touchUp with wait actions to simulate long-presses, flings with custom inertia, or anything the higher-level scroll doesn't cover:

const cx = client.deviceInfo.screenWidth / 2;
const startY = 600;
const endY = 200;

await client.performActions([
  { type: 'touchDown', x: cx, y: startY },
  { type: 'wait', durationMs: 30 },
  { type: 'touchMove', x: cx, y: startY - 100 },
  { type: 'touchMove', x: cx, y: startY - 200 },
  { type: 'touchMove', x: cx, y: endY },
  { type: 'touchUp',   x: cx, y: endY },
]);

Install, launch, and terminate apps

Install from a URL or local file, launch by bundle ID, terminate when you're done. listApps enumerates what's already on the device, system apps included.

const apps = await client.listApps();
// [{ bundleId, name, installType }, ...]

await client.launchApp('com.example.MyApp');
await client.launchApp('com.example.MyApp', 'RelaunchIfRunning');
await client.terminateApp('com.example.MyApp');

// Install from a URL (md5 enables server-side caching)
await client.installApp('https://...build.zip', {
  md5: '...',
  timeoutMs: 120_000,
  launchMode: 'ForegroundIfRunning',
});
apps, err := client.ListApps(ctx)
// []InstalledApp{ BundleID, Name, InstallType }

err = client.LaunchApp(ctx, "com.example.MyApp")
result, err := client.InstallApp(ctx, "https://...build.zip", &iosws.AppInstallationOptions{
    MD5:        "...",
    LaunchMode: "ForegroundIfRunning",
})
// terminate-app is TypeScript-only today; fall back to `lim ios terminate-app` or REST.

Or from the CLI:

lim ios list-apps
lim ios launch-app com.example.MyApp --mode RelaunchIfRunning
lim ios terminate-app com.example.MyApp
lim ios install-app ./MyApp.app.zip
lim ios install-app https://... --md5 abc... --launch-mode ForegroundIfRunning

Reset app data between runs

Between test cases or scenario reruns, wipe the app's data container and relaunch it. Faster than uninstalling and reinstalling.

await client.softReset('com.example.MyApp');                       // strategy: 'data' (default)
await client.softReset('com.example.MyApp', { strategy: 'full' }); // also clears caches, keychain, and privacy grants
// returns: { strategy, bundleId, itemsCleared?, durationMs }

Read app logs

Tail the last N lines once, or stream live. The stream emits one line event per log line (batched ~500ms server-side), plus error and close.

// One-shot tail
const tail = await client.appLogTail('com.example.MyApp', 200);

// Stream live
const stream = client.streamAppLog('com.example.MyApp');
stream.on('line', (line) => console.log(line));
stream.on('error', console.error);
stream.on('close', () => console.log('stream closed'));
stream.stop();   // unsubscribe when done

Or from the CLI:

lim ios app-log com.example.MyApp --tail 200
lim ios app-log com.example.MyApp --follow

Record a video

Recordings run server-side. quality accepts the integers 5 through 10; the server default is 5. Save to a local file, hand the bytes to a presigned URL, or both.

await client.startRecording({ quality: 5 });
// ... drive the UI ...
const downloadUrl = await client.stopRecording({ localPath: '/tmp/demo.mp4' });
// or:
await client.stopRecording({ presignedUrl: '<your-s3-upload-url>' });

Or from the CLI:

lim ios record start --quality 5
# ... drive the UI ...
lim ios record stop -o /tmp/demo.mp4
# or upload directly:
lim ios record stop --presigned-url "<url>"

Reverse-tunnel a local service

Expose a TCP service on your machine to the simulator. Use it for anything the app inside the simulator needs to call into: a mock backend, a debug proxy, a test runner, or a local Expo dev server so a React Native app on the remote simulator can hot-reload from your laptop without a public tunnel.

const tunnel = await client.startReverseTunnel({
  remotePort: 57090,           // must be in 57090..57099
  localPort: 4000,             // defaults to remotePort
  localHost: '127.0.0.1',
});
// The simulator can now reach this service at 127.0.0.1:57090
// tunnel.close() to tear it down

Or from the CLI:

lim ios reverse 57090:4000             # expose local :4000 as simulator :57090
lim ios reverse 57091 --local-host 0.0.0.0

The remote port must be in 57090-57099. That range is reserved for tunneled services. Tooling that picks its own port (the canonical expo start CLI defaults to 8081, for example) needs the port made configurable before the tunnel works; the Expo PR-preview Action wraps full app builds instead of dev-server hot-reload, so it doesn't go through this path.

Drop into macOS dev tools

When the high-level API doesn't cover what you need, the instance exposes a small set of macOS dev tools as escape hatches.

// Streaming simctl
const exec = client.simctl(['listapps', 'booted']);
exec.on('line-stdout', (line) => console.log(line));
exec.on('line-stderr', (line) => console.error(line));
const { code } = await exec.wait();

// One-shot xcrun (limited to --sdk / --show-* flags)
const { stdout } = await client.xcrun(['--sdk', 'iphonesimulator', '--show-sdk-version']);

// xcodebuild version only
await client.xcodebuild(['-version']);

// Upload a file to the simulator sandbox (returns the in-sandbox path)
const remotePath = await client.cp('config.json', '/local/path/to/config.json');

// Inspect open UNIX sockets
const sockets = await client.lsof();
// simctl mirrors os/exec.Cmd; pipe to your own writers or buffer the output.
cmd := client.Simctl(ctx, "listapps", "booted")
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
if err := cmd.Run(); err != nil { panic(err) }

// Inspect open UNIX sockets
sockets, err := client.Lsof(ctx)

// xcrun, xcodebuild, and cp are TypeScript-only today.

Or from the CLI:

lim ios simctl -- listapps booted
lim ios xcrun  -- --sdk iphonesimulator --show-sdk-version
lim ios cp config.json /local/path/to/config.json
lim ios lsof

Reuse and clean up instances

Each iOS instance is single-tenant. Only one client should drive it at a time, so scope instances per user, per PR, or per session with labels. reuseIfExists returns the same instance on the next call with the same labels, which keeps a long-running agent on one warm pod instead of churning through fresh ones.

The pattern matters when a platform integrator triggers create from a UI action: without reuseIfExists, a tight loop on the user's side fans out into hundreds of instances (and a real-world bill). With it, repeated clicks settle on the same warm pod.

// Reuse the same instance for the same session across multiple agent runs
const instance = await lim.iosInstances.create({
  wait: true,
  reuseIfExists: true,
  metadata: { labels: { session: sessionId, user: userId } },
});

Tear down explicitly when you're done:

await lim.iosInstances.delete(instance.metadata.id);
lim ios delete <id>    # or `lim ios delete` for the last-created

Or clean up by label selector:

const stale = await lim.iosInstances.list({ labelSelector: `user=${userId}`, state: 'ready' });
for await (const inst of stale) {
  await lim.iosInstances.delete(inst.metadata.id);
}

The console's Instances tab shows the same data in a UI. Filter by state, filter by label selector, sort by duration or created-at, and re-attach to any ready instance with one click:

Console Instances page showing one ready iOS instance with its ID, status, duration, and creation time

After a delete (or after the inactivity timeout fires), the row disappears from the Ready filter. Switching the filter to All shows the same instance in terminated state until it ages out:

Console Instances page after deleting the ready instance, with no rows under the Ready filter

Configure the instance

By default, instances are an iPhone in a nearby region with a server-applied inactivity timeout. To pick a different model, pin a region, pre-install an app, or attach an Xcode build sandbox, pass spec.* at create time:

const instance = await lim.iosInstances.create({
  wait: true,
  reuseIfExists: true,
  metadata: {
    labels: { app: 'my-app', session: 'demo' },
  },
  spec: {
    model: 'iphone',                    // 'iphone' | 'ipad' | 'watch'
    region: 'us-west',
    inactivityTimeout: '10m',           // '1m' | '10m' | '3h' | ...
    hardTimeout: '0',                   // '0' = no hard cap
    clues: [{ kind: 'ClientIP', clientIp: '203.0.113.42' }],
    initialAssets: [
      { kind: 'App', source: 'AssetName', assetName: 'my-app-build.zip', launchMode: 'ForegroundIfRunning' },
    ],
    sandbox: { xcode: { enabled: true } },
  },
});
instance = client.ios_instances.create(
    wait=True,
    reuse_if_exists=True,
    metadata={"labels": {"app": "my-app", "session": "demo"}},
    spec={
        "model": "iphone",                # "iphone" | "ipad" | "watch"
        "region": "us-west",
        "inactivity_timeout": "10m",
        "hard_timeout": "0",
        "clues": [{"kind": "ClientIP", "client_ip": "203.0.113.42"}],
        "initial_assets": [
            {
                "kind": "App",
                "source": "AssetName",
                "asset_name": "my-app-build.zip",
                "launch_mode": "ForegroundIfRunning",
            },
        ],
        "sandbox": {"xcode": {"enabled": True}},
    },
)
// The Go SDK does not expose `model` on the create params yet.
// Instances default to iPhone. To pick iPad or Apple Watch, use the CLI or REST.
instance, err := lim.IosInstances.New(ctx, limrun.IosInstanceNewParams{
    Wait:          param.NewOpt(true),
    ReuseIfExists: param.NewOpt(true),
    Metadata: limrun.IosInstanceNewParamsMetadata{
        Labels: map[string]string{"app": "my-app", "session": "demo"},
    },
    Spec: limrun.IosInstanceNewParamsSpec{
        Region:            param.NewOpt("us-west"),
        InactivityTimeout: param.NewOpt("10m"),
        HardTimeout:       param.NewOpt("0"),
        Sandbox: limrun.IosInstanceNewParamsSpecSandbox{
            Xcode: limrun.IosInstanceNewParamsSpecSandboxXcode{
                Enabled: param.NewOpt(true),
            },
        },
    },
})

Or from the CLI:

lim ios create --reuse-if-exists \
  --model iphone \
  --region us-west \
  --inactivity-timeout 10m \
  --label app=my-app --label session=demo \
  --install-asset my-app-build.zip \
  --xcode

spec reference

FieldTypeMeaning
model'iphone' | 'ipad' | 'watch'Apple Simulator model. Defaults to iphone.
regionstringPin region (e.g. us-west). Otherwise scheduled by clues + availability.
inactivityTimeoutduration string1m, 10m, 3h. Default comes from your org settings. Passing 0 is equivalent to omitting it.
hardTimeoutduration stringForced termination. Default 0 = no hard cap.
cluesarrayScheduling hints. { kind: 'ClientIP', clientIp } picks a region close to the end user.
initialAssetsarrayApps to install at boot. See Asset Storage.
sandbox.xcode.enabledbooleanAttach an Xcode build sandbox. See Build with remote Xcode.

metadata.labels is free-form key=value and powers reuseIfExists, listing, and cleanup queries.

For platform integrators: pass the end user's IP as a ClientIP clue (clues: [{ kind: 'ClientIP', clientIp: '203.0.113.42' }]). The scheduler places the instance in a region close to that IP, which is the difference between a snappy embedded simulator and a laggy one across continents.

Console Playground iOS model picker showing iPhone, iPad, and Apple Watch options

Next steps

hammer

Build with remote Xcode

Produce the build that gets installed onto the simulator.

git-pull-request

Automatic PR Previews

Ship every PR with a built app and a live preview link, straight from a GitHub workflow.

monitor-play

Embed the simulator

Render the simulator in your web app with <RemoteControl />.

credit-card

Test in-app purchases

StoreKit local testing without an Apple Account or sandbox tester (advanced).