Llim.run

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:

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

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:

https://api.limrun.com

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 LIM_API_KEY environment variable by default.

The SDK clients take the key as a constructor argument, which can be omitted when LIM_API_KEY is set; with curl, pass it as a bearer token:

import Limrun from '@limrun/api';

const lim = new Limrun({
  apiKey: process.env['LIM_API_KEY'],   // defaults to LIM_API_KEY env, can be omitted
});
import os
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
curl https://api.limrun.com/v1/ios_instances \
  -H "Authorization: Bearer $LIM_API_KEY"

Credentials

The SDKs use the credentials described in Concepts. Two SDK specifics matter:

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:

const session = await lim.scopedTokens.create({
  scopes: ['device:*:install', `asset:${assetId}:read`],
  ttlSeconds: 900,
});

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:

curl -X DELETE https://api.limrun.com/v1/scoped_tokens/$ID \
  -H "Authorization: Bearer $LIM_API_KEY"

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.

CapabilityTypeScriptPythonGo
iOS and Android instance create, list, get, delete✓✓✓
Xcode instance create, list, get, delete✓ (xcodeInstances)✓ (xcode_instances)POST /v1/xcode_instances over REST, or lim xcode create
Gradle instance create, list, get, delete✓ (gradleInstances)REST, or lim gradle createREST, 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)✓✓ (get_or_create)✓ (Assets.GetOrNew)
Scoped tokens (scopedTokens.create)✓RESTREST
Usage analytics (analytics.get, analytics.getInstances)✓RESTREST
Asset ttl on upload✓not exposednot 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 buildlim xcode build
Gradle source sync and remote builds✓ (gradleInstances.createClient)lim gradle buildlim gradle build
Escrowed Android release signing (--sign)pass the signing material yourselflim gradle build --signlim 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 supportednot supported
iOS reverse tunnel (startReverseTunnel)✓not supportednot supported
iOS app-log streaming✓not supportednot supported
iOS video recording✓not supportednot supported
iOS camera from a video file (setCameraVideo)✓not supportednot supported
StoreKit local-config helpers✓not supportednot supported
Android device-control client (screenshot, element tree, tap, type, scroll, open URL)✓adb over the tunneladb over the tunnel
Android video recording✓ (startRecording / stopRecording)adb screenrecordadb screenrecord
Android APK install on a running instance✓ (sendAsset)adb install or lim android install-appadb install or lim android install-app
Android connection lifecycle hooks✓not exposednot 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 onlyregion only
spec.model on iOS create✓✓not exposed; defaults to iPhone, use the CLI or REST
spec.model on Android create✓not exposednot exposed
ClientLocation clue✓not exposednot 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:

StatusTypeScriptPythonMeaning
400BadRequestErrorBadRequestErrorMalformed input or an invalid combination of parameters.
401AuthenticationErrorAuthenticationErrorMissing or invalid API key.
403PermissionDeniedErrorPermissionDeniedErrorThe API key lacks the necessary permission.
404NotFoundErrorNotFoundErrorThe instance, asset, or other resource does not exist.
409ConflictErrorConflictErrorThe request conflicts with the resource's current state. Deleting an instance that is already gone is not a conflict; it returns 200.
422UnprocessableEntityErrorUnprocessableEntityErrorThe request shape is valid but semantically rejected.
429RateLimitErrorRateLimitErrorRate limit hit. Retry with backoff.
≥500InternalServerErrorInternalServerErrorServer-side issue. The SDKs retry automatically.
n/aAPIConnectionErrorAPIConnectionErrorNetwork failure before a response.
n/aAPIConnectionTimeoutErrorAPITimeoutErrorThe request timed out.
n/aAPIUserAbortErrornoneThe 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:

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;
  }
}

Timeouts, retries, and logging

Each SDK sets timeouts and retries on the client and lets you override them per request:

SDKDefault timeoutDefault retriesOptions
TypeScript5 minutes2timeout, maxRetries
Python5 minutes (5 s to connect)2timeout, max_retries
Go v0.9.0none; set one with a context deadline2option.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:

ParameterTypeMeaning
waitbooleanReturn only after the instance reaches ready. Without it, the call returns at once with state: 'creating'.
reuseIfExistsbooleanIf 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:

lim ios create --reuse-if-exists --model iphone --jurisdiction us --force-bundle-id com.example.myapp --label session=demo
const instance = await lim.iosInstances.create({
  wait: true,
  reuseIfExists: true,
  metadata: { labels: { session: 'demo' } },
  spec: {
    model: 'iphone',
    jurisdiction: 'us',
    forceBundleId: 'com.example.myapp',
  },
});
instance = client.ios_instances.create(
    wait=True,
    reuse_if_exists=True,
    metadata={"labels": {"session": "demo"}},
    spec={
        "model": "iphone",
    },
)
// 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"},
    },
})
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",
      "jurisdiction": "us",
      "forceBundleId": "com.example.myapp"
    }
  }'

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.

lim ios list --label-selector "session=demo" --state ready
// Auto-paginating
for await (const inst of lim.iosInstances.list({ labelSelector: 'session=demo', state: 'ready' })) {
  console.log(inst.metadata.id);
}

// Single page
let page = await lim.iosInstances.list({ limit: 50 });
for (const inst of page.items) console.log(inst.metadata.id);
while (page.hasNextPage()) {
  page = await page.getNextPage();
  for (const inst of page.items) console.log(inst.metadata.id);
}
params = {"label_selector": "session=demo", "state": "ready"}
while page := client.ios_instances.list(**params).items:
    for inst in page:
        print(inst.metadata.id)
    # Page by hand: the iterator stops after the first page.
    params["starting_after"] = page[-1].metadata.id
// Page by hand: ListAutoPaging panics on instance lists.
params := limrun.IosInstanceListParams{
    LabelSelector: param.NewOpt("session=demo"),
    State:         param.NewOpt("ready"),
}
for {
    page, err := client.IosInstances.List(ctx, params)
    if err != nil {
        panic(err)
    }
    if len(page.Items) == 0 {
        break
    }
    for _, inst := range page.Items {
        fmt.Println(inst.Metadata.ID)
    }
    params.StartingAfter = param.NewOpt(page.Items[len(page.Items)-1].Metadata.ID)
}
curl "https://api.limrun.com/v1/ios_instances?labelSelector=session%3Ddemo&state=ready" \
  -H "Authorization: Bearer $LIM_API_KEY"

Get and delete

Get and delete take the instance ID:

lim ios get <instance-id>
lim ios delete <instance-id>
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)
curl https://api.limrun.com/v1/ios_instances/<instance-id> -H "Authorization: Bearer $LIM_API_KEY"
curl -X DELETE https://api.limrun.com/v1/ios_instances/<instance-id> -H "Authorization: Bearer $LIM_API_KEY"

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:

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 get_or_create 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 get_or_create, then PUT the bytes to signed_upload_url when md5 is missing or differs.

Upload, list, get, and delete assets:

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>
// 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_filter="my-app.tar.gz", 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{
    NameFilter:         param.NewOpt("my-app.apk"),
    IncludeDownloadURL: param.NewOpt(true),
})
got, _ := client.Assets.Get(ctx, id, limrun.AssetGetParams{IncludeDownloadURL: param.NewOpt(true)})
_ = client.Assets.Delete(ctx, id)
# 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

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:

BoundMeaning
gteAt or after the timestamp.
lteAt or before the timestamp.

To retrieve instances that stopped during a window, regardless of when they started, supply only stoppedAt:

const stopped = await client.analytics.getInstances({
  stoppedAt: {
    gte: "2026-09-25T00:00:00Z",
    lte: "2026-09-26T00:00:00Z",
  },
});

To narrow that to instances that started at or before the window start, add startedAt:

const carriedOver = await client.analytics.getInstances({
  startedAt: { lte: "2026-09-25T00:00:00Z" },
  stoppedAt: {
    gte: "2026-09-25T00:00:00Z",
    lte: "2026-09-26T00:00:00Z",
  },
  labels: "customer=acme",
});

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

layers

Instance spec and status

Every create field, state, and status URL.

terminal

CLI reference

Every lim command, shared flags, and environment variables.

smartphone

Run a simulator

The device-control surface behind instance.status.apiUrl.

tablet-smartphone

Run an emulator

The ADB tunnel and the Android device-control client.