Llim.run

SDK reference

The OpenAPI spec is the source of truth. Stainless generates each SDK from it, so method names and parameter shapes are consistent across languages once you account for casing conventions (camelCase in TypeScript, snake_case in Python, PascalCase in Go).

Base URL

https://api.limrun.com

Override with a per-client option in TypeScript, Python, or Go.

Authentication

Every request authenticates with a single API key (prefix lim_). Generate keys at console.limrun.com.

The CLI and every SDK accept the key from the LIM_API_KEY environment variable by default:

curl https://api.limrun.com/v1/ios_instances \
  -H "Authorization: Bearer $LIM_API_KEY"
import Limrun from '@limrun/api';

const lim = new Limrun({
  apiKey: process.env['LIM_API_KEY'],   // defaults to LIM_API_KEY env, can be omitted
});
from limrun_api import Limrun

client = Limrun(
    api_key=os.environ.get("LIM_API_KEY"),   # defaults to LIM_API_KEY env, can be omitted
)
import (
    "github.com/limrun-inc/go-sdk"
    "github.com/limrun-inc/go-sdk/option"
)

client := limrun.NewClient(option.WithAPIKey("..."))  // defaults to LIM_API_KEY env
export LIM_API_KEY=lim_...

Credentials

Two credentials show up in most workflows. Mixing them up is the most common integration bug.

CredentialWhere you get itUse for
Org API key (lim_...)console.limrun.com → Settings → API Keys; lim login; or LIM_API_KEYREST control plane (api.limrun.com), including create/list/delete. Also MCP: Authorization: Bearer <org API key> on instance.status.mcpUrl.
Per-instance token (instance.status.token)Returned in status when the instance is readyapiUrl device daemon, endpointWebSocketUrl (append ?token=), adbWebSocketUrl, sandbox.xcode.url, and SDK helpers like Ios.createInstanceClient. Embedded in signedStreamUrl for browser streaming.

The org API key must never ship to a browser or end-user client. For embedded simulators, your backend holds the API key and passes only endpointWebSocketUrl + status.token to <RemoteControl />. See Embed the simulator.

Install snippets

If you'd rather skip the SDK, call the API directly with curl. No install required.

# npm
npm install @limrun/api

# pnpm
pnpm add @limrun/api

# bun
bun add @limrun/api
pip install limrun_api
go get -u github.com/limrun-inc/go-sdk@latest
# npm
npm install --global @limrun/cli

# pnpm
pnpm add --global @limrun/cli

# bun
bun add --global @limrun/cli

SDK capability matrix

The three SDKs are not equivalent. Every one covers the control plane (create / get / list / delete instances and assets). Beyond that, only the TypeScript SDK is complete; the others vary. The CLI is the easiest fallback for anything your SDK doesn't cover; raw HTTP is the universal escape hatch.

CapabilityTypeScriptPythonGo
Instance CRUD (iOS, Android, Xcode)iOS + Android only (no XcodeInstances service)
Asset CRUD
assets.getOrUpload (MD5 dedup + signed PUT)upload manually✓ (Assets.GetOrUpload)
iOS device control (taps, screenshot, element-tree, typeText, simctl streaming, ...)use REST or CLI✓ (subset, see below)
Android device control over the SDKuse REST + ADBuse REST + ADB
ADB tunnel for Android✓ (startAdbTunnel)use external adb✓ (tunnel.NewADB, plus Multiplexed)
Xcode source-sync + remote xcodebuild✓ (xcodeInstances.createClient)use lim xcode builduse lim xcode build
iOS reverse tunnel✓ (startReverseTunnel)not supportednot supported
iOS app-log streamingnot supportednot supported
iOS video recordingnot supportednot supported
StoreKit local-config helpersnot supportednot supported

The Go iOS WebSocket client (github.com/limrun-inc/go-sdk/websocket/ios) covers screenshot, element-tree, tap, tap-element, type-text, press-key, launch-app, list-apps, open-url, install-app, lsof, set-orientation, increment/decrement/set element value, and simctl streaming. It does not include terminate-app, scroll, performActions, or video recording today.

If you're on Python and need any of the device-control or build surfaces, the canonical option is the lim CLI from @limrun/cli (shell out from your code) or raw REST calls against the URLs returned in instance.status. The CLI is feature-complete; the SDKs catch up later.

Errors

Errors are returned as JSON with an HTTP status code that maps to a typed error class in every SDK.

StatusTypeScript / Python / GoMeaning
400BadRequestErrorMalformed input or invalid combination of parameters.
401AuthenticationErrorMissing or invalid API key.
403PermissionDeniedErrorAPI key lacks the necessary permission.
404NotFoundErrorThe instance, asset, or other resource does not exist.
409ConflictErrorConflicting state (e.g. instance already terminated).
422UnprocessableEntityErrorValidation error: request shape is valid but semantically rejected.
429RateLimitErrorRate limit hit. Retry with backoff.
≥500InternalServerErrorServer-side issue. SDKs auto-retry by default.
n/aAPIConnectionErrorNetwork failure before a response.
n/aAPIConnectionTimeoutErrorRequest timed out.
n/aAPIUserAbortErrorThe request was aborted by the caller.

The SDKs auto-retry on 408, 409, 429, 5xx, and connection errors. The default is 2 retries with exponential backoff. Configure or disable with the per-client maxRetries option.

import Limrun from '@limrun/api';

const lim = new Limrun({ maxRetries: 5 });

try {
  const instance = await lim.iosInstances.create({ wait: true });
} catch (err) {
  if (err instanceof Limrun.APIError) {
    console.log(err.status, err.name, err.headers);
  } else {
    throw err;
  }
}

Query params: wait and reuseIfExists

Two query params show up on every create call across resources.

ParamTypeMeaning
waitbooleanReturn only after the instance reaches ready. Without wait, the call returns immediately with state: 'creating'.
reuseIfExistsbooleanIf an instance with the same (region, labels) exists, return it instead of creating a new one.

Use wait: true for interactive workflows where you need the URLs immediately. Use reuseIfExists: true whenever you want repeated calls (CLI re-runs, retries, the same PR's CI runs) to converge on the same instance.

Instance state machine

Every iOS, Android, and Xcode instance moves through the same lifecycle, from creating while hardware is being provisioned, through assigned while it boots, to ready when the URLs in status are usable, and finally to terminated.

   creating ──┬──► assigned ──► ready ──► terminated

              └──► (error) ──► terminated  (status.errorMessage set)
StateMeaning
unknownDefault placeholder; should not be returned for live instances.
creatingProvisioning. URLs in status are not yet populated.
assignedHardware has been assigned; instance is booting.
readyFully booted. All URLs populated. Safe to connect a control client.
terminatedStopped, either by explicit delete, inactivity timeout, hard timeout, or error.

instance.status.errorMessage is set when the instance terminated due to an error.

Status URL surface

Every successful wait: true create returns a status block with the URLs the data plane needs.

FieldiOSAndroidXcodeUse
tokenPer-instance bearer for apiUrl, endpointWebSocketUrl, adbWebSocketUrl, sandbox.xcode.url; embedded in signedStreamUrl.
apiUrlHTTP base for the per-instance device daemon (SDK / CLI control client).
signedStreamUrlBrowser watch URL with token embedded (no org API key in the browser).
endpointWebSocketUrlWebSocket for <RemoteControl />; pass ?token=<status.token>.
mcpUrlPer-instance MCP HTTP endpoint. Auth with org API key header, not status.token. See MCP.
adbWebSocketUrlADB tunnel endpoint. Pass as adbUrl to createInstanceClient.
targetHttpPortUrlPrefixReverse-proxy prefix for tunneled HTTP ports (used by Appium/WDA).
sandbox.xcode.url✓ (conditional)Xcode sandbox API URL when spec.sandbox.xcode.enabled: true.
sandbox.playwrightAndroid.url✓ (conditional)CDP URL when spec.sandbox.playwrightAndroid.enabled: true.

Regions and labels

spec.region pins an instance to a region (e.g. eu-north1, us-east1). If omitted, Limrun schedules based on spec.clues and current availability. The console's Analytics tab breaks down runtime minutes per platform and per region, which is the canonical place to see which regions an org has used:

Console Analytics tab showing daily runtime minutes for Android, iOS, and XCode, broken down by eu-north1 and us-east1 regions

metadata.labels is a free-form { [key: string]: string } map. The same labels do three jobs at once: they identify instances for reuseIfExists, they filter listings via labelSelector (a comma-separated key=value list), and they group instances for bulk operations like "delete everything tagged pr=42."

Useful label keys for most workflows are tenant, user, session, pr, repo, agent, and managed_by. Avoid keys that might overlap with metadata Limrun adds internally.

Resources × CRUD

The four resources (iosInstances, androidInstances, xcodeInstances, assets) all support the same five operations. Method names follow each language's convention.

Create an iOS instance

curl -X POST "https://api.limrun.com/v1/ios_instances?wait=true&reuseIfExists=true" \
  -H "Authorization: Bearer $LIM_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "metadata": { "labels": { "session": "demo" } },
    "spec": {
      "model": "iphone",
      "region": "us-west",
      "sandbox": { "xcode": { "enabled": true } }
    }
  }'
const instance = await lim.iosInstances.create({
  wait: true,
  reuseIfExists: true,
  metadata: { labels: { session: 'demo' } },
  spec: {
    model: 'iphone',
    region: 'us-west',
    sandbox: { xcode: { enabled: true } },
  },
});
instance = client.ios_instances.create(
    wait=True,
    reuse_if_exists=True,
    metadata={"labels": {"session": "demo"}},
    spec={
        "model": "iphone",
        "region": "us-west",
        "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 := client.IosInstances.New(ctx, limrun.IosInstanceNewParams{
    Wait:          param.NewOpt(true),
    ReuseIfExists: param.NewOpt(true),
    Metadata: limrun.IosInstanceNewParamsMetadata{
        Labels: map[string]string{"session": "demo"},
    },
    Spec: limrun.IosInstanceNewParamsSpec{
        Region: param.NewOpt("us-west"),
        Sandbox: limrun.IosInstanceNewParamsSpecSandbox{
            Xcode: limrun.IosInstanceNewParamsSpecSandboxXcode{
                Enabled: param.NewOpt(true),
            },
        },
    },
})
lim ios create --reuse-if-exists --xcode \
  --model iphone --region us-west \
  --label session=demo

List iOS instances

curl "https://api.limrun.com/v1/ios_instances?labelSelector=session%3Ddemo&state=ready" \
  -H "Authorization: Bearer $LIM_API_KEY"
// Auto-paginating
for await (const inst of lim.iosInstances.list({ labelSelector: 'session=demo', state: 'ready' })) {
  console.log(inst.metadata.id);
}

// Single page
const page = await lim.iosInstances.list({ limit: 50 });
for (const inst of page.items) console.log(inst.metadata.id);
while (page.hasNextPage()) {
  const next = await page.getNextPage();
  // ...
}
for inst in client.ios_instances.list(label_selector="session=demo", state="ready"):
    print(inst.metadata.id)
iter := client.IosInstances.ListAutoPaging(ctx, limrun.IosInstanceListParams{
    LabelSelector: param.NewOpt("session=demo"),
    State:         param.NewOpt("ready"),
})
for iter.Next() {
    inst := iter.Current()
    fmt.Println(inst.Metadata.ID)
}
lim ios list --label-selector "session=demo" --state ready

Get / Delete an iOS instance

curl https://api.limrun.com/v1/ios_instances/<id> -H "Authorization: Bearer $LIM_API_KEY"
curl -X DELETE https://api.limrun.com/v1/ios_instances/<id> -H "Authorization: Bearer $LIM_API_KEY"
const inst = await lim.iosInstances.get(id);
await lim.iosInstances.delete(id);
inst = client.ios_instances.get(id)
client.ios_instances.delete(id)
inst, _ := client.IosInstances.Get(ctx, id)
_ = client.IosInstances.Delete(ctx, id)
lim ios get <id>
lim ios delete <id>           # or `lim ios delete` for the last-created

Android instances

The Android operations have the same shape as iOS. Substitute androidInstances (TS), android_instances (Python), AndroidInstances (Go), or lim android * (CLI). The Android-specific pieces are clues accepting { kind: 'OSVersion', osVersion: '13'|'14'|'15' }, the multi-asset shapes on initialAssets, and spec.sandbox.playwrightAndroid.enabled.

Xcode instances

The TypeScript and Python SDKs expose a top-level xcodeInstances / xcode_instances resource with the same CRUD shape as iOS. The Go SDK does not ship an XcodeInstances service today; reach the same instances through one of these paths:

Assets

Assets use getOrCreate (upsert; named GetOrNew in Go) and getOrUpload (upsert plus actual upload) instead of create. The os field on Asset is optional; leaving it unset means the asset is available for both platforms. The getOrUpload helper that handles MD5 dedup and the signed PUT ships in the TypeScript and Go SDKs; Python callers do the two-step pattern manually (call get_or_create, then PUT the bytes to signedUploadUrl if md5 is missing or doesn't match).

# Two-step upload: get-or-create returns a signed PUT URL, then push the bytes to it.
curl -X PUT https://api.limrun.com/v1/assets \
  -H "Authorization: Bearer $LIM_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "name": "my-app.tar.gz" }'
# returns signedUploadUrl + signedDownloadUrl

curl -X PUT "$SIGNED_UPLOAD_URL" \
  -H "Content-Type: application/octet-stream" \
  --data-binary @./my-app.tar.gz
// One-shot: getOrUpload computes MD5, skips re-upload if unchanged
const asset = await lim.assets.getOrUpload({ path: './my-app.tar.gz', name: 'my-app.tar.gz' });

// Manual two-step
const asset2 = await lim.assets.getOrCreate({ name: 'my-app.tar.gz' });
if (!asset2.md5) {
  await fetch(asset2.signedUploadUrl, {
    method: 'PUT',
    body: await fs.promises.readFile('./my-app.tar.gz'),
    headers: { 'Content-Type': 'application/octet-stream' },
  });
}

// List
const list = await lim.assets.list({ namePrefixFilter: 'my-app-', includeDownloadUrl: true });

// Get / delete
const a = await lim.assets.get(id, { includeDownloadUrl: true });
await lim.assets.delete(id);
upserted = client.assets.get_or_create(name="my-app.tar.gz")
# ... PUT bytes to upserted.signed_upload_url ...
listed   = client.assets.list(name_prefix_filter="my-app-", include_download_url=True)
fetched  = client.assets.get(id, include_download_url=True)
client.assets.delete(id)
// Helper: getOrUpload
asset, _ := client.Assets.GetOrUpload(ctx, limrun.AssetGetOrUploadParams{
    Path: "./my-app.apk",
})
// Or lower-level: GetOrNew, then PUT to signedUploadUrl yourself.

list, _ := client.Assets.List(ctx, limrun.AssetListParams{
    NamePrefixFilter:   param.NewOpt("my-app-"),
    IncludeDownloadURL: param.NewOpt(true),
})
got, _ := client.Assets.Get(ctx, id, limrun.AssetGetParams{IncludeDownloadURL: param.NewOpt(true)})
_ = client.Assets.Delete(ctx, id)
lim asset push ./my-app.tar.gz --name my-app-v1.tar.gz
lim asset list --name my-app-v1.tar.gz --download-url
lim asset list <id>
lim asset pull <id_or_name> --output ./downloads/
lim asset delete <id>

Auto-pagination

list endpoints in TypeScript, Python, and Go return iterable objects you can range over directly.

for await (const inst of lim.iosInstances.list()) {
  console.log(inst.metadata.id);
}
for inst in client.ios_instances.list():
    print(inst.metadata.id)
iter := client.IosInstances.ListAutoPaging(ctx, limrun.IosInstanceListParams{})
for iter.Next() {
    fmt.Println(iter.Current().Metadata.ID)
}

OpenAPI spec

The REST API is documented on lim.run and in each SDK's generated reference (typescript-sdk/api.md, python-sdk/api.md, go-sdk/api.md). The SDKs are generated from the OpenAPI spec via Stainless.

Timeouts, retries, logging

All three SDKs default to a 5-minute request timeout and 2 retries with exponential backoff. Configure both per-client with the timeout and maxRetries options, or override per-request.

The TypeScript SDK also accepts a logger and logLevel ('debug' | 'info' | 'warn' | 'error' | 'off'), settable through the LIMRUN_LOG env var. At 'debug' the SDK logs full HTTP requests and responses. Auth headers are redacted, but sensitive data in request and response bodies is not, so keep that in mind before turning debug logging on in production.

Next steps

rocket

Quickstart

The 3-minute path from npm install to a running simulator with your app on it.

smartphone

Run an iOS Simulator

The data-plane surface that lives behind instance.status.apiUrl.

tablet-smartphone

Run an Android Emulator

ADB tunnel and Android device-control surface.

bot

CLI for coding agents

Every lim command tuned for agent workflows.