Llim.run

Run an Android Emulator

Limrun spins up real Android emulators in the cloud. Drive them from the LIM SDK, or open an Android Debug Bridge (adb) tunnel and use anything that speaks adb: Android Studio, Appium, scrcpy, your own shell scripts.

This page covers:

  1. Starting an Android instance.
  2. Connecting to it with the LIM SDK or adb.
  3. Reading the screen and driving the device.
Limrun console Playground tab running a cloud Android emulator with the Lawnchair launcher visible on the streamed device

Prerequisites

SDK capability matrix

The three SDKs cover different slices of the Android surface. TypeScript ships the full device-control client; from Python and Go, drive the device over the adb tunnel.

CapabilityTypeScriptPythonGo
Instance CRUD
Asset CRUD
assets.getOrUpload (local file uploader)upload manually
Provision Playwright-Android sub-sandbox
Device-control clientnot exposednot exposed
Screenshot, element treeuse adb over the tunneluse adb over the tunnel
Tap, type, scroll, open URLuse adb over the tunneluse adb over the tunnel
Video recording (startRecording / stopRecording)use adb screenrecorduse adb screenrecord
APK installuse adb installuse adb install
Connection lifecyclenot exposednot exposed

Provision an instance

With the CLI or SDK set up and authenticated, provisioning is a simple command:

# Boot an instance and open an ADB tunnel (defaults: --connect=true).
lim android create

# Auto-delete the pod when this CLI process exits, and pin to a region with a label.
lim android create --rm --region us-west --label app=demo --label session=docs

# Pre-install a local APK (uploaded to Asset Storage first, MD5-deduplicated).
lim android create --install ./build.apk

# Reference an asset that's already in storage by name.
lim android create --install-asset chrome-stable
import { Limrun } from '@limrun/api';

const lim = new Limrun();   // reads LIM_API_KEY from the environment

const instance = await lim.androidInstances.create({
  wait: true,            // resolve only after status.state === 'ready'
  reuseIfExists: true,   // return the existing match instead of creating a new pod
  metadata: { labels: { app: 'demo', session: 'docs' } },
  spec: {
    region: 'us-west',           // optional; otherwise picked by clues + availability
    inactivityTimeout: '10m',    // 1m, 10m, 3h, ...
    hardTimeout: '0',            // '0' = no hard cap
    clues: [{ kind: 'OSVersion', osVersion: '15' }],   // '13' | '14' | '15'
  },
});

console.log(instance.metadata.id, instance.status.state);
import (
    "context"
    "os"
    limrun "github.com/limrun-inc/go-sdk"
    "github.com/limrun-inc/go-sdk/option"
    "github.com/limrun-inc/go-sdk/packages/param"
)

lim := limrun.NewClient(option.WithAPIKey(os.Getenv("LIM_API_KEY")))

inst, _ := lim.AndroidInstances.New(ctx, limrun.AndroidInstanceNewParams{
    Wait:          param.NewOpt(true),
    ReuseIfExists: param.NewOpt(true),
    Metadata: limrun.AndroidInstanceNewParamsMetadata{
        Labels: map[string]string{"app": "demo", "session": "docs"},
    },
    Spec: limrun.AndroidInstanceNewParamsSpec{
        Region:            param.NewOpt("us-west"),
        InactivityTimeout: param.NewOpt("10m"),
        HardTimeout:       param.NewOpt("0"),
        Clues: []limrun.AndroidInstanceNewParamsSpecClue{
            {Kind: "OSVersion", OsVersion: param.NewOpt("15")},
        },
    },
})
from limrun_api import Limrun

lim = Limrun()  # reads LIM_API_KEY

instance = lim.android_instances.create(
    wait=True,
    reuse_if_exists=True,
    metadata={"labels": {"app": "demo", "session": "docs"}},
    spec={
        "region": "us-west",
        "inactivity_timeout": "10m",
        "hard_timeout": "0",
        "clues": [{"kind": "OSVersion", "os_version": "15"}],
    },
)

Create parameters

Flags for lim android create:

FlagUse this to
--region <name>Pin the region (e.g. us-west). Otherwise scheduled by availability.
--display-name <name>Set the human-readable name shown in the console.
--label key=valueAttach a metadata label. Repeat for multiple labels.
--reuse-if-existsReturn the existing instance with matching labels + region instead of creating a new pod. Defaults to false.
--inactivity-timeout <duration>Inactivity timeout (1m, 10m, 3h). Defaults to the organization setting.
--hard-timeout <duration>Forced termination after this wall-clock window. Defaults to no cap.
--install <path>Pre-install a local APK while provisioning. Repeatable. See Pre-install APKs.
--install-asset <name>Pre-install an APK that's already in Asset Storage. Repeatable. See Pre-install APKs.
--rmAuto-delete the pod when the CLI process exits. Defaults to false.
--connect / --no-connectOpen the ADB tunnel after the instance is ready. Defaults to true.
--adb-path <path>Point the CLI at a non-default adb binary. Defaults to adb (looked up on PATH).

spec.clues and spec.sandbox.playwrightAndroid are SDK-only.

FieldTypeMeaning
waitbooleanWhen true, the call returns only after status.state === 'ready'. Without it, you'll need to poll. Defaults to false.
reuseIfExistsbooleanWhen true, return the existing instance with matching metadata.labels and spec.region instead of creating a new pod. Defaults to false.
metadata.displayNamestringHuman-readable name surfaced in the web console alongside metadata.id.
metadata.labelsRecord<string, string>Free-form key=value map for tagging and grouping.
spec.regionstringPin region (e.g. us-west). When omitted, the scheduler picks based on clues plus availability.
spec.inactivityTimeoutduration string1m, 10m, 3h, etc. Defaults to 3m. Passing '0' defers to the organization-level default.
spec.hardTimeoutduration stringForced termination after this wall-clock window. '0' means no cap; the pod runs until inactivity or explicit delete. Defaults to 0.
spec.clues[].kind'ClientIP' | 'OSVersion'Scheduling hint: bias by OS version or by user location.
spec.clues[].clientIpstringRequired when kind: 'ClientIP'. Set this to the IP of the end user whose browser will stream the emulator. The scheduler geolocates this IP and provisions in the nearest region for low-latency streaming. spec.region overrides this clue.
spec.clues[].osVersionstringRequired when kind: 'OSVersion'. One of '13', '14', '15'.
spec.initialAssets[]array of InitialAssetAPKs to install while provisioning. See Pre-install APKs.
spec.sandbox.playwrightAndroid.enabledbooleanProvision a Playwright-Android sub-sandbox alongside the emulator and surface its CDP URL on status.sandbox.playwrightAndroid.url. Defaults to false. See Automated testing > Playwright.

Pre-install APKs

Set initialAssets at provisioning time to install one or more APKs while the pod boots. Each APK must come from a publicly reachable HTTPS URL (a GitHub release asset, an S3 presigned URL) or Limrun's Asset Storage.

For local files, the CLI's --install and the TypeScript/Go SDK's assets.getOrUpload({ path }) upload to Asset Storage first and hand the daemon a signed URL. Python's SDK doesn't ship that helper. Upload manually with assets.get_or_create plus a PUT to the returned upload URL before calling create. See Asset Storage.

# Local APK uploaded to Asset Storage on the fly, then installed.
lim android create --install ./build.apk

# Multiple unrelated apps; each --install becomes its own install group.
lim android create --install ./app-a.apk --install ./app-b.apk

# Already in Asset Storage? Reference it by name instead.
lim android create --install-asset chrome-stable
const instance = await lim.androidInstances.create({
  wait: true,
  reuseIfExists: true,
  spec: {
    initialAssets: [
      // One asset by name (already uploaded via assets.getOrUpload)
      { kind: 'App', source: 'AssetName', assetName: 'my-app-build.apk' },

      // Direct URLs, no Asset Storage upload required
      { kind: 'App', source: 'URL',
        url: 'https://example.com/builds/123.apk' },
      { kind: 'App', source: 'URLs',
        urls: ['https://example.com/base.apk', 'https://example.com/config.apk'] },

      // Reference by asset ID (when you already have one)
      { kind: 'App', source: 'AssetIDs',
        assetIds: ['asset_01j...'] },
    ],
  },
});
inst, _ := lim.AndroidInstances.New(ctx, limrun.AndroidInstanceNewParams{
    Wait:          param.NewOpt(true),
    ReuseIfExists: param.NewOpt(true),
    Spec: limrun.AndroidInstanceNewParamsSpec{
        InitialAssets: []limrun.AndroidInstanceNewParamsSpecInitialAsset{
            // One asset by name
            {
                Kind:      "App",
                Source:    "AssetName",
                AssetName: param.NewOpt("my-app-build.apk"),
            },
            // Direct URL
            {
                Kind:   "App",
                Source: "URL",
                URL:    param.NewOpt("https://example.com/builds/123.apk"),
            },
            // Multiple URLs as one install group
            {
                Kind:   "App",
                Source: "URLs",
                URLs:   []string{"https://example.com/base.apk", "https://example.com/config.apk"},
            },
            // Reference by asset ID
            {
                Kind:     "App",
                Source:   "AssetIDs",
                AssetIDs: []string{"asset_01j..."},
            },
        },
    },
})
instance = lim.android_instances.create(
    wait=True,
    reuse_if_exists=True,
    spec={
        "initial_assets": [
            # One asset by name (already uploaded via assets.get_or_upload)
            {"kind": "App", "source": "AssetName", "asset_name": "my-app-build.apk"},

            # Direct URLs
            {"kind": "App", "source": "URL",
             "url": "https://example.com/builds/123.apk"},
            {"kind": "App", "source": "URLs",
             "urls": ["https://example.com/base.apk", "https://example.com/config.apk"]},

            # Reference by asset ID
            {"kind": "App", "source": "AssetIDs",
             "asset_ids": ["asset_01j..."]},
        ],
    },
)

Using Split APKs

If you ship your app as split APKs, ie a base.apk plus a few config.* APKs (config.xxhdpi.apk for screen density, config.en.apk for locale, config.arm64_v8a.apk for architecture, etc.), the whole set has to install as one atomic group or the app won't run. Pass them together by switching source to AssetNames or URLs and giving the entry an array. The first filename is treated as the base APK; the rest are config APKs.

Split APKs are SDK-only. Each CLI --install / --install-asset becomes its own initialAssets[] entry, so the set installs as separate apps rather than a grouped install. Use an SDK to keep them in one entry.

await lim.androidInstances.create({
  wait: true,
  spec: {
    initialAssets: [
      // From Asset Storage by name
      { kind: 'App', source: 'AssetNames',
        assetNames: ['base.apk', 'config.xxhdpi.apk', 'config.en.apk'] },

      // Or from any HTTPS URLs the daemon can reach
      { kind: 'App', source: 'URLs',
        urls: [
          'https://example.com/base.apk',
          'https://example.com/config.xxhdpi.apk',
          'https://example.com/config.en.apk',
        ] },
    ],
  },
});
inst, _ := lim.AndroidInstances.New(ctx, limrun.AndroidInstanceNewParams{
    Wait: param.NewOpt(true),
    Spec: limrun.AndroidInstanceNewParamsSpec{
        InitialAssets: []limrun.AndroidInstanceNewParamsSpecInitialAsset{
            {
                Kind:       "App",
                Source:     "AssetNames",
                AssetNames: []string{"base.apk", "config.xxhdpi.apk", "config.en.apk"},
            },
            {
                Kind:   "App",
                Source: "URLs",
                URLs: []string{
                    "https://example.com/base.apk",
                    "https://example.com/config.xxhdpi.apk",
                    "https://example.com/config.en.apk",
                },
            },
        },
    },
})
lim.android_instances.create(
    wait=True,
    spec={
        "initial_assets": [
            {"kind": "App", "source": "AssetNames",
             "asset_names": ["base.apk", "config.xxhdpi.apk", "config.en.apk"]},

            {"kind": "App", "source": "URLs",
             "urls": [
                 "https://example.com/base.apk",
                 "https://example.com/config.xxhdpi.apk",
                 "https://example.com/config.en.apk",
             ]},
        ],
    },
)

Use separate entries in initialAssets when you want to install unrelated apps in sequence; everything inside one entry is treated as a single grouped install.

Set up custom Chrome Flags

You can use Android's local Chrome's experimental features by turning on a specific flag. This is SDK-only. To do this, add an entry to initialAssets array with kind: 'Configuration':

{
  kind: 'Configuration',
  configuration: {
    kind: 'ChromeFlag',
    chromeFlag: 'enable-command-line-on-non-rooted-devices@1',
  },
}
limrun.AndroidInstanceNewParamsSpecInitialAsset{
    Kind: "Configuration",
    Configuration: limrun.AndroidInstanceNewParamsSpecInitialAssetConfiguration{
        Kind:       "ChromeFlag",
        ChromeFlag: "enable-command-line-on-non-rooted-devices@1",
    },
}
{
    "kind": "Configuration",
    "configuration": {
        "kind": "ChromeFlag",
        "chrome_flag": "enable-command-line-on-non-rooted-devices@1",
    },
}

Anything you want to upload once and reuse goes through Asset Storage first. For builds you want to push to an already-running pod, see Install an APK.

Example response

A successful create returns the full instance resource.

{
  "metadata": {
    "id": "android_usw1_01krxvphn8e3rbeq0zewgw209a",
    "createdAt": "2026-05-18T19:52:42Z",
    "organizationId": "org_01kr52j...",
    "displayName": "android_usw1_01krxvphn8e3rbeq0zewgw209a",
    "labels": { "app": "demo", "session": "docs" }
  },
  "spec": {
    "inactivityTimeout": "10m0s",
    "region": "us-west"
  },
  "status": {
    "state": "ready",
    "apiUrl":               "https://us-west-1-1234567.limrun.net/v1/android_usw1_01krxv.../api",
    "adbWebSocketUrl":      "wss://us-west-1-1234567.limrun.net/v1/organizations/org_01kr52j.../android.limrun.com/v1/instances/android_usw1_01krxv.../adbWebSocket",
    "endpointWebSocketUrl": "wss://us-west-1-1234567.limrun.net/v1/organizations/org_01kr52j.../android.limrun.com/v1/instances/android_usw1_01krxv.../endpointWebSocket",
    "signedStreamUrl":      "https://console.limrun.com/signedStream?token=lim_...&url=wss%3A%2F%2Fus-west-1-1234567.limrun.net%2F...%2FendpointWebSocket",
    "mcpUrl":               "https://us-west-1-1234567.limrun.net/v1/android_usw1_01krxv.../mcp",
    "targetHttpPortUrlPrefix": "wss://us-west-1-1234567.limrun.net/v1/organizations/org_01kr52j.../android.limrun.com/v1/instances/android_usw1_01krxv.../targetHttpPort",
    "token": "lim_..."
  }
}

Watch the livestream

Field reference

FieldUse
metadata.idStable instance identifier. Pass to get, delete, connect.
metadata.labelsEchoed back. Drives reuseIfExists matching and label-selector listing.
spec.regionRegion the scheduler placed the pod in.
status.stateunknown, creating, assigned, ready, terminated.
status.apiUrlHTTP base for the Android control daemon. WebSocket control derives from it (apiUrl + /ws). Pass to createInstanceClient.
status.adbWebSocketUrlWebSocket endpoint for the ADB tunnel. Pass to createInstanceClient as adbUrl.
status.endpointWebSocketUrlWebSocket endpoint for the <RemoteControl /> web component (append ?token=).
status.signedStreamUrlConsole-hosted watch URL with the token baked in. Open in a browser without your API key.
status.mcpUrlPer-instance MCP HTTP endpoint. Authenticate with your org API key, not status.token. See MCP.
status.targetHttpPortUrlPrefixWebSocket prefix (wss://...) for reaching HTTP services running on the device. Append your target port to forward traffic through.
status.sandbox.playwrightAndroid.urlChrome DevTools Protocol URL when the Playwright sub-sandbox is enabled.
status.tokenPer-instance bearer for apiUrl, adbWebSocketUrl, and endpointWebSocketUrl. Embedded in signedStreamUrl.

Connect to your instance

Two ways to drive the device:

  1. LIM SDK. Opens a typed WebSocket session to the Android control daemon. Tight, ergonomic surface for the common moves (tap by selector, screenshot, element tree, record).
  2. ADB tunnel. Exposes the emulator's adb socket as a local TCP listener. Anything that speaks adb attaches as if it were a USB device.

Use the SDK when you want strongly-typed results and built-in selectors. Use the ADB tunnel when you need the long tail of Android tooling (logcat, file push/pull, debugger attach, scrcpy, Appium) or you're calling from Go or Python.

CapabilityLIM SDKADB tunnel
Screenshotclient.screenshot() returns a base64 PNG data URIadb shell screencap -p > screen.png
Read UI hierarchyclient.getElementTree() returns AndroidElementNode[] plus raw XMLadb shell uiautomator dump, then parse /sdcard/window_dump.xml
Find element by selectorclient.findElement(selector, limit?) with typed AndroidSelectordump the tree, then grep/parse XML manually
Tap by selectorclient.tap({ selector }) (server-side lookup + tap)dump tree, parse bounds, then adb shell input tap X Y
Tap by coordinatesclient.tap({ x, y })adb shell input tap X Y
Type textclient.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)
Scrollclient.scrollScreen('down', 6) / scrollElement(target, ...)adb shell input swipe X1 Y1 X2 Y2
Open URL / deeplinkclient.openUrl('myapp://...')adb shell am start -a android.intent.action.VIEW -d <url>
Record videoclient.startRecording / stopRecording with localPath / presignedUrl sinksadb shell screenrecord /sdcard/out.mp4 (3-minute cap per file), then adb pull
Install APK from HTTPS URLclient.sendAsset(url) (daemon downloads on the device side)download locally, then adb install ./out.apk
Install APK from local fileassets.getOrUpload({ path }) then sendAsset(asset.signedDownloadUrl)adb install ./build.apk
Launch / force-stop appnot exposedadb shell am start -n pkg/.Activity / adb shell am force-stop pkg
Logcatnot exposedadb logcat
File push / pullnot exposedadb push local /sdcard/... / adb pull /sdcard/...
Run arbitrary shellnot exposedadb shell <anything>
Mirror the device UIuse signedStreamUrl in a browser or <RemoteControl />scrcpy -s 127.0.0.1:<tunnel-port>
Attach Android Studio / IDEnot applicablepair Device Manager with 127.0.0.1:<tunnel-port>
Drive with Appiumnot applicablepoint appium:udid at 127.0.0.1:<tunnel-port>
Connection lifecycletyped getConnectionState / onConnectionStateChange with auto-reconnectadb-server's built-in retry; adb reconnect if it loses the socket
LanguagesTypeScript (device control + tunnel); Go and Python: tunnel onlyany language that can shell out to adb

Create a client for the LIM SDK

createInstanceClient opens a WebSocket to the Android control daemon. Every method on the returned client is one round-trip over that socket.

You'll need two fields from the create response:

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

const client = await createInstanceClient({
  apiUrl: instance.status.apiUrl!,
  token:  instance.status.token,
  logLevel: 'info',
});
OptionWhat it controls
apiUrlHTTP base for the Android control daemon. The WebSocket endpoint is derived as apiUrl + /ws. Recording downloads use the same base. Required.
tokenPer-instance bearer sent as Authorization: Bearer <token> on the WebSocket handshake. Required.
logLevelOne of 'none' | 'error' | 'warn' | 'info' | 'debug'. Logs are prefixed [Endpoint]. Defaults to info.
maxReconnectAttemptsMax consecutive reconnect attempts after a transient WebSocket drop before the client gives up. Defaults to 6.
reconnectDelayInitial backoff between reconnect attempts. Each attempt doubles, capped at maxReconnectDelay. Defaults to 1000 ms.
maxReconnectDelayUpper bound on the exponential backoff window. Defaults to 30000 ms.

The Go and Python SDKs don't currently expose a device-control client; from those languages, drive the device through the ADB tunnel or call the REST surface directly.

Start an ADB tunnel

lim android connect opens a long-lived WebSocket between your machine and the emulator's adb socket, binds it to a random 127.0.0.1 port, and runs adb connect 127.0.0.1:<port> for you. The tunnel stays up until you Ctrl+C. The emulator then shows up under adb devices like any networked device.

# Connect to the last-created Android instance.
lim android connect

# Or target a specific instance (recommended for scripts).
lim android connect --id <ID>

# Point at a non-default adb binary.
lim android connect --id <ID> --adb-path /opt/homebrew/bin/adb

In another terminal, verify it's wired up and run anything adb understands:

adb devices
# List of devices attached
# 127.0.0.1:49923   device

adb -s 127.0.0.1:49923 shell getprop ro.build.version.release
# 15

Some tools that can work with adb:

Read the screen

Three primitives for inspecting the device using the SDK. All of these require an initiated client:

Take a screenshot

screenshot captures the current frame as a base64-encoded PNG. The CLI writes the decoded bytes to a path you supply; the SDK returns a data URI that you decode yourself.

lim android screenshot screen.png
lim android screenshot screen.png --id <ID>
const shot = await client.screenshot();
// { dataUri: 'data:image/png;base64,iVBORw0K...' }

import { writeFileSync } from 'node:fs';
const png = Buffer.from(shot.dataUri.replace(/^data:image\/\w+;base64,/, ''), 'base64');
writeFileSync('screen.png', png);

Record the screen

Recording is server-side and writes to the emulator's filesystem. Start a recording, drive the UI, then stop. The output is H.264 in an mp4 container.

lim android record start --quality 5

# ...drive the UI...

# Download to a local file (default: timestamped mp4 in cwd).
lim android record stop -o demo.mp4

# Or hand the bytes to a presigned upload URL.
lim android record stop --presigned-url https://example.com/upload
await client.startRecording({ quality: 5 });

// ...drive the UI...

const url = await client.stopRecording({ localPath: '/tmp/demo.mp4' });
// url is also returned so you can re-fetch later

quality accepts the integers 5 through 10. Higher values increase bitrate and file size.

On the SDK, pick the sink that matches where the file should live:

Get the element tree

# Print the raw XML.
lim android element-tree

# Or get the parsed nodes as JSON (agent-friendly).
lim android element-tree --json
const tree = await client.getElementTree();
// tree.xml    raw UIAutomator XML
// tree.nodes  flattened AndroidElementNode[]

Sample raw tree.xml output:

<?xml version='1.0' encoding='UTF-8' standalone='yes' ?>
<hierarchy rotation="0">
  <!-- ...ancestors elided... -->
  <node index="3"
        text="Chrome"
        resource-id=""
        class="android.widget.TextView"
        package="app.lawnchair"
        content-desc="Chrome"
        checkable="false"
        checked="false"
        clickable="true"
        enabled="true"
        focusable="true"
        focused="false"
        scrollable="false"
        long-clickable="true"
        password="false"
        selected="false"
        bounds="[533,1222][706,1422]" />
</hierarchy>

Sample tree.nodes output:

{
  "index": "3",
  "text": "Chrome",
  "resourceId": "",
  "className": "android.widget.TextView",
  "packageName": "app.lawnchair",
  "contentDesc": "Chrome",
  "clickable": true,
  "enabled": true,
  "focusable": true,
  "focused": false,
  "scrollable": false,
  "selected": false,
  "bounds": "[533,1222][706,1422]",
  "parsedBounds": {
    "left": 533, "top": 1222, "right": 706, "bottom": 1422,
    "centerX": 619, "centerY": 1322
  }
}

Control the device

Input actions need a way to specify what to act on. That shape is AndroidElementTarget, which carries either a selector or explicit coordinates.

type AndroidElementTarget = {
  selector?: AndroidSelector;
  x?: number;
  y?: number;
};

Prefer selectors. Layouts shift between OS versions; coordinates don't survive that.

Install an APK

sendAsset(url) tells the daemon to download an APK and install it. The URL has to be publicly reachable (a GitHub release asset, an S3 presigned URL) or come from Limrun's Asset Storage.

The CLI's lim android install-app accepts either a path or a URL: paths are uploaded to Asset Storage first (MD5-deduplicated), URLs are passed straight to the daemon. From TypeScript / Go, upload local files with assets.getOrUpload({ path }) and pass the returned signedDownloadUrl to sendAsset. Python users upload manually with assets.get_or_create plus a PUT before calling sendAsset. See Asset Storage.

# Local path: uploaded to Asset Storage first, then installed.
lim android install-app ./build.apk

# Remote URL: passed straight to the daemon.
lim android install-app https://example.com/build.apk --id <ID>
// Local file: upload once, then install via the returned signed URL.
const asset = await lim.assets.getOrUpload({ path: './build.apk' });
await client.sendAsset(asset.signedDownloadUrl);

// Or install straight from a public URL.
await client.sendAsset('https://example.com/build.apk');

// Default request timeout is 120s. Pass a custom timeout for larger APKs.
await client.sendAsset(asset.signedDownloadUrl, 180_000);

Select an element

findElement queries the tree without you having to parse it. Every non-empty selector field is ANDed together.

# Find by text, return up to 5 matches as JSON.
lim android find-element --text "Sign In" --limit 5 --json

# Find by resource id; defaults to up to 20 matches.
lim android find-element --resource-id com.example:id/submit

# Filter for clickable buttons that are enabled.
lim android find-element --class-name android.widget.Button --clickable --enabled
const r = await client.findElement({ text: 'Chrome' }, 5);
// { count: 1, elements: [ { ... AndroidElementNode } ] }

Selector fields (SDK key on the left, equivalent CLI flag on the right):

SDK fieldCLI flagMatches
resourceId--resource-id <value>android:id/... resource string. Most stable selector.
text--text <value>Visible text. Case-sensitive exact match.
contentDesc--content-desc <value>Accessibility content description.
className--class-name <value>Widget class (android.widget.Button, androidx.viewpager.widget.ViewPager, etc.).
packageName--package-name <value>Owning app package. Useful to scope queries when the launcher is in front.
index--index <n>Sibling index.
clickable--clickable / --no-clickableFilter to clickable nodes only.
enabled--enabled / --no-enabledSkip disabled controls.
focused--focused / --no-focusedFind the currently focused control.
boundsContains--bounds-contains-x <n> --bounds-contains-y <n>Find the deepest node whose bounds contain that pixel.

Tap, type, or press

# Tap by selector (preferred). Pass any selector flag from the table above.
lim android tap-element --resource-id com.example.app:id/login
lim android tap-element --text "Sign In"

# Tap by raw coordinates (fallback).
lim android tap 374 219

# Type into the focused field, or into a specific input via selector / coords.
lim android type "hello"
lim android type "docs sandbox" --class-name android.widget.EditText
lim android type "hello" --x 120 --y 340

# Hardware and IME keys; --modifier is repeatable.
lim android press-key HOME
lim android press-key BACK
lim android press-key enter
lim android press-key a --modifier shift
// Tap by selector (preferred)
await client.tap({ selector: { resourceId: 'com.example.app:id/login' } });
await client.tap({ selector: { text: 'Chrome' } });

// Tap by coords (fallback)
await client.tap({ x: 374, y: 219 });

// Type into the currently focused field, or into a specific input
await client.setText(undefined, 'hello');
await client.setText(
  { selector: { className: 'android.widget.EditText' } },
  'docs sandbox',
);

// Hardware and IME keys
await client.pressKey('HOME');                 // returns { key: 'KEYCODE_HOME' }
await client.pressKey('BACK');
await client.pressKey('ENTER');
await client.pressKey('a', ['shift']);         // modifiers: shift, ctrl/control, alt/option, meta/command/cmd, sym, fn

pressKey accepts plain names (BACK, ENTER, A, TAB), digit strings ('4'), or fully-qualified KEYCODE_* constants. The response echoes the resolved keycode so you can confirm what fired.

Scroll

scrollScreen performs a finger-swipe gesture from the screen center. scrollElement (CLI: pass selector flags to lim android scroll) scrolls inside a specific scrollable node such as a list, ViewPager, or ScrollView. Both take a direction and an amount in Android scroll units.

# Scroll the screen. --amount defaults to 300 in the CLI.
lim android scroll down --amount 500
lim android scroll up

# Scroll inside a specific element by selector or coords.
lim android scroll down --resource-id com.example:id/list --amount 500
lim android scroll up --x 120 --y 500 --amount 250
// SDK amount defaults to 6.
await client.scrollScreen('down', 6);
await client.scrollScreen('up');
await client.scrollScreen('left', 4);
await client.scrollElement(
  { selector: { className: 'android.widget.ScrollView' } },
  'down',
  3,
);

Valid directions: 'up' | 'down' | 'left' | 'right'. The SDK call resolves with the gesture's start and end points so you can sanity-check what landed on the wire:

{ "direction": "down", "startX": 360, "startY": 808, "endX": 360, "endY": 1108 }

The SDK defaults amount to 6 (small flick); the CLI defaults to 300 (a hefty scroll). The field on the wire is the same; pass the same number to both for identical behavior.

Open URLs

openUrl resolves the intent the same way am start -a android.intent.action.VIEW -d <url> would. It works for web URLs and registered deeplink schemes.

lim android open-url https://example.com
lim android open-url myapp://orders/42 --id <ID>
await client.openUrl('https://example.com');           // launches in the default browser
await client.openUrl('myapp://orders/42');             // routes to your app's deeplink handler

If no installed activity matches the intent, the daemon rejects with startActivity returned code -91 (Android's ActivityManager.START_INTENT_NOT_RESOLVED). This catches unregistered custom schemes (myapp://...) and standard schemes whose handler isn't on the base image. On the stock image, mailto: fails (no mail app), while tel: and https:// succeed (dialer + Chrome are present). Install the missing handler via Pre-install APKs at boot, or Install an APK on a running pod.

Manage the connection

The TypeScript client exposes lifecycle hooks for long-running drivers and connection debugging.

Keep-alive

The client auto-pings the WebSocket every 30 seconds and surfaces state changes through a callback. Use keepAlive for an explicit application-level ping (during a long idle stretch, before a known-flaky network transition), and getConnectionState / onConnectionStateChange to observe.

client.getConnectionState();
// 'connecting' | 'connected' | 'disconnected' | 'reconnecting'

const unsubscribe = client.onConnectionStateChange((state) => {
  console.log('state →', state);
});

client.keepAlive();    // explicit application-level ping

The SDK auto-reconnects on transient failures with exponential backoff (defaults: 6 attempts, 1 s base, 30 s cap).

Disconnect

disconnect closes the WebSocket cleanly and disables auto-reconnect. It does not delete the instance; the emulator keeps running until its inactivity or hard timeout fires.

client.disconnect();
unsubscribe();         // drop the state callback if you no longer need it

Delete

Each Android instance is single-tenant: only one client should drive it at a time. Scope instances per user, per PR, or per session with labels, then let reuseIfExists return the same warm pod on the next call. Tear down explicitly when you're done, or clean up by label selector across a tenant.

lim android list                       # all ready instances (use --all for every state)
lim android list --label-selector user=alice
lim android delete <ID>                # tear down a specific instance
lim android delete                     # tear down the last-created instance
// Single instance
await lim.androidInstances.delete(instance.metadata.id);

// Or every instance matching a label
for await (const inst of lim.androidInstances.list({
  labelSelector: `user=${userId}`,
  state: 'ready',
})) {
  await lim.androidInstances.delete(inst.metadata.id);
}
// Single instance
_ = lim.AndroidInstances.Delete(ctx, inst.Metadata.ID)

The inactivity timeout fires if nobody talks to the daemon for that long. The hard timeout fires regardless. Set both to your tolerance for orphaned pods.

See also

Automated testing with Appium

Wire any upstream Appium server to the ADB tunnel for native or hybrid Android tests.

Automated testing with Playwright

Drive Chrome on the emulator with Playwright via the playwrightAndroid sub-sandbox.

Embed the simulator

Stream the live emulator into your web app with <RemoteControl />.

Asset Storage

Upload APKs once, reuse them across instances via initialAssets.