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/apipip install limrun_apigo get -u github.com/limrun-inc/go-sdk@latestTo 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.comEach 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 envcurl 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:
- The client constructor takes the API key and calls the control plane on
api.limrun.com. Keep it on your backend; never ship it to a browser or an end-user client. - Device clients such as
Ios.createInstanceClientandcreateInstanceClient(Android) take the instance'sstatus.apiUrland its instance token,status.token, and need no API key. Instance tokens are returned only to credentials that can control the instance; a read-only API key or a viewer seat gets notoken.status.streamTokenopens onlyendpointWebSocketUrl, so a browser can stream the device whilestatus.tokenstays on your backend. For embedded devices, your backend passesendpointWebSocketUrland a token to<RemoteControl />; see Embed a device.
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.
| Capability | TypeScript | Python | Go |
|---|---|---|---|
| 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 create | REST, 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) | ✓ | REST | REST |
Usage analytics (analytics.get, analytics.getInstances) | ✓ | REST | REST |
Asset ttl on upload | ✓ | not exposed | not 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 build | lim xcode build |
| Gradle source sync and remote builds | ✓ (gradleInstances.createClient) | lim gradle build | lim gradle build |
Escrowed Android release signing (--sign) | pass the signing material yourself | lim gradle build --sign | lim 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 supported | not supported |
iOS reverse tunnel (startReverseTunnel) | ✓ | not supported | not supported |
| iOS app-log streaming | ✓ | not supported | not supported |
| iOS video recording | ✓ | not supported | not supported |
iOS camera from a video file (setCameraVideo) | ✓ | not supported | not supported |
| StoreKit local-config helpers | ✓ | not supported | not supported |
| Android device-control client (screenshot, element tree, tap, type, scroll, open URL) | ✓ | adb over the tunnel | adb over the tunnel |
| Android video recording | ✓ (startRecording / stopRecording) | adb screenrecord | adb screenrecord |
| Android APK install on a running instance | ✓ (sendAsset) | adb install or lim android install-app | adb install or lim android install-app |
| Android connection lifecycle hooks | ✓ | not exposed | not 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 only | region only |
spec.model on iOS create | ✓ | ✓ | not exposed; defaults to iPhone, use the CLI or REST |
spec.model on Android create | ✓ | not exposed | not exposed |
ClientLocation clue | ✓ | not exposed | not 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:
| Status | TypeScript | Python | Meaning |
|---|---|---|---|
400 | BadRequestError | BadRequestError | Malformed input or an invalid combination of parameters. |
401 | AuthenticationError | AuthenticationError | Missing or invalid API key. |
403 | PermissionDeniedError | PermissionDeniedError | The API key lacks the necessary permission. |
404 | NotFoundError | NotFoundError | The instance, asset, or other resource does not exist. |
409 | ConflictError | ConflictError | The request conflicts with the resource's current state. Deleting an instance that is already gone is not a conflict; it returns 200. |
422 | UnprocessableEntityError | UnprocessableEntityError | The request shape is valid but semantically rejected. |
429 | RateLimitError | RateLimitError | Rate limit hit. Retry with backoff. |
≥500 | InternalServerError | InternalServerError | Server-side issue. The SDKs retry automatically. |
| n/a | APIConnectionError | APIConnectionError | Network failure before a response. |
| n/a | APIConnectionTimeoutError | APITimeoutError | The request timed out. |
| n/a | APIUserAbortError | none | The 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:
| SDK | Default timeout | Default retries | Options |
|---|---|---|---|
| TypeScript | 5 minutes | 2 | timeout, maxRetries |
| Python | 5 minutes (5 s to connect) | 2 | timeout, max_retries |
| Go v0.9.0 | none; set one with a context deadline | 2 | option.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:
| Parameter | Type | Meaning |
|---|---|---|
wait | boolean | Return only after the instance reaches ready. Without it, the call returns at once with state: 'creating'. |
reuseIfExists | boolean | If 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=democonst 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:
- Call
POST /v1/xcode_instances(or the Gradle equivalent) over HTTP. - Or shell out to
lim xcode createandlim xcode build, orlim gradle createandlim gradle build.
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.gzThe 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:
| Bound | Meaning |
|---|---|
gte | At or after the timestamp. |
lte | At 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
Instance spec and status
Every create field, state, and status URL.
CLI reference
Every lim command, shared flags, and environment variables.
Run a simulator
The device-control surface behind instance.status.apiUrl.
Run an emulator
The ADB tunnel and the Android device-control client.
Was this guide helpful?