@run-cloud/sdk
v0.29.0
Published
TypeScript client for run.cloud, including sandbox-provider compatibility adapters
Maintainers
Keywords
Readme
@run-cloud/sdk
TypeScript client for the run.cloud API.
npm install @run-cloud/sdk
export RUN_CLOUD_API_KEY="rc_live_..."import { readFile, writeFile } from "node:fs/promises";
import { Client } from "@run-cloud/sdk";
const cloud = new Client();
const ios = await cloud.ios.create({
displayName: "CI smoke",
idempotencyKey: "ci-smoke:run-123",
});
await cloud.ios.install(
ios.id,
new Blob([await readFile("Newly.app.tar.gz")], { type: "application/gzip" }),
{ filename: "Newly.app.tar.gz" },
);
await cloud.ios.keepAlive(ios.id);
const opened = await cloud.ios.openUrl(ios.id, "https://example.com");
console.log(opened.sessionId, opened.device, opened.leaseId, opened.url);
await cloud.ios.tap(ios.id, { x: 0.5, y: 0.75 });
await cloud.ios.typeText(ios.id, "Hello from iOS!");
await writeFile("ios-screenshot.png", await cloud.ios.screenshot(ios.id));
await cloud.ios.delete(ios.id);
const android = await cloud.android.create({ displayName: "Android smoke" });
await cloud.android.swipe(
android.id,
{ x: 0.5, y: 0.8 },
{ x: 0.5, y: 0.2 },
{ durationMs: 250 },
);
await cloud.android.pressKey(android.id, "enter");
await writeFile("android-screenshot.png", await cloud.android.screenshot(android.id));
const recording = await cloud.android.startRecording(android.id, {
idempotencyKey: "android-readme-example",
});
await cloud.android.openUrl(android.id, "https://run.cloud");
await cloud.android.stopRecording(android.id, recording.id);
await writeFile(
"android-recording.mp4",
await cloud.android.downloadRecording(android.id, recording.id),
);
await cloud.android.delete(android.id);Xcode builds
const submitted = await cloud.xcode.build(process.cwd(), {
project: "MyApp.xcodeproj",
scheme: "MyApp",
sdk: "iphonesimulator",
idempotencyKey: `xcode-${process.env.GITHUB_SHA ?? "local"}`,
});
const completed = await cloud.xcode.wait(submitted.id, {
onLog: (text) => process.stdout.write(text),
});
if (completed.status !== "succeeded") {
throw new Error(`${completed.failure?.reason}\n${completed.failure?.action}`);
}
await writeFile("MyApp.tar.gz", await cloud.xcode.downloadArtifact(completed.id));
const rebuilt = await cloud.xcode.rebundle(process.cwd(), completed.id, {
idempotencyKey: `rebundle-${completed.id}-${process.env.GITHUB_SHA ?? "local"}`,
});
const ready = await cloud.xcode.wait(rebuilt.id);
const launch = await cloud.mobile.launch(
{ buildId: ready.id },
{ idempotencyKey: `launch-${ready.id}`, orgId: ready.orgId },
);
console.log(launch.id, launch.sessionId, launch.status);Build records, sanitized logs, failure reasons, and artifacts stay available by
ID to members of the owning organization. Source packaging honors the root
.gitignore, drops common Xcode and dependency output, and supports explicit
include, ignore, and additional-file rules. Full options and intentional
differences are documented at
docs.run.cloud/ios/build-with-xcode.
Use cloud.xcode.events for replayable lifecycle delivery and
cloud.capabilities.create for short-lived access to selected resources.
Retry-safe simulator creates
Simulator creates are retry-safe when idempotencyKey is set. Reuse the same
key after a timeout or lost response to receive the original session instead
of leasing another device.
Install and keep active
cloud.ios.install(id, application, options) and
cloud.android.install(id, application, options) upload an application as a
user-owned asset and install it into an active session. If an asset is already
uploaded, use installAsset(id, assetId) to avoid another transfer.
Call keepAlive(id) while a user is actively viewing a session that has an
inactivity timeout. A fleet-confirmed missing device is reported as a
non-retryable simulator_gone error; temporary transport failures remain
retryable and do not claim the device has ended.
Connect local Metro
openMetroTunnel(id, { localPort }) returns a short-lived reverse-tunnel
capability that lets the caller connect a loopback Metro server to an active
iOS or Android session. Start the tunnel sidecar before opening the development
client URL inside the simulator.
const tunnel = await cloud.ios.openMetroTunnel(ios.id, { localPort: 8081 });
await startTrustedTunnelSidecar(tunnel);The descriptor contains tunnel credentials and must be treated as a secret. It never contains the fleet lease token or private integration URL. The tunnel is fixed to loopback Metro traffic; callers cannot choose another remote service through this method.
URLs and deep links
cloud.ios.openUrl(id, url), cloud.android.openUrl(id, url), and
cloud.simulators.openUrl(id, url, { platform }) return a typed
SimulatorOpenUrlResult. It contains ok, platform, sessionId, device,
leaseId, and the exact input url without decoding or normalization:
const target =
"runcloudproof://open/items%2F42?message=hello%20world&return=" +
"https%3A%2F%2Fexample.com%2Fdone%3Fx%3D1%26y%3Dtwo#proof";
const result = await cloud.android.openUrl(android.id, target);
if (result.url !== target) throw new Error("deep-link encoding changed");The target app must be installed and register its custom scheme. See
Open URLs and Deep Links for CLI,
SDK, and REST examples, platform behavior, validation, and stable error codes.
On iOS, the first custom-scheme handoff can show an Open in “App”? system
confirmation after openUrl returns; approve it in the viewer or with an
authenticated simulator tap before asserting visible app state.
Simulator interactions
Both cloud.ios and cloud.android expose the same interaction methods:
tap, swipe, gesture, typeText, pressKey, pressButton, rotate,
reload, scroll, toggleSoftwareKeyboard, simulateMemoryWarning,
rotateDigitalCrown, and setRenderDebug. Coordinates use inclusive normalized
display space: (0, 0) is the top-left corner and (1, 1) is the bottom-right.
typeText uses a US keyboard and accepts tabs, line feeds, and printable ASCII.
On iOS, home, appSwitcher, and recents are momentary navigation actions;
physical buttons honor durationMs.
The shared orientation type includes portrait_upside_down for Android.
Current iPhone Simulator sessions support portrait, landscape_left, and
landscape_right; an upside-down request throws a non-retryable
RunCloudError with code unsupported_action and supported-orientation
details.
Digital Crown input is not supported by current iOS Simulator or Android
Emulator sessions; the typed method returns an actionable API error.
Android Emulator sessions also report capsLock, numLock, and scrollLock
as unsupported semantic keys.
Every method returns a correlated, action-narrowed acknowledgement. Use
interact when the action is assembled dynamically:
const result = await cloud.simulators.interact(
ios.id,
{ action: "tap", x: 0.5, y: 0.5 },
{ platform: "ios", requestId: "open-settings", timeoutMs: 15_000 },
);
console.log(result.requestId, result.action, result.status, result.result.pointCount);Pass an AbortSignal to stop waiting without releasing the session. The SDK
sends the requested timeout to run.cloud and leaves a short response margin so
the server's completion or timeout acknowledgement can arrive.
const controller = new AbortController();
process.once("SIGINT", () => controller.abort());
await cloud.android.typeText(android.id, "cancel-safe", {
signal: controller.signal,
timeoutMs: 15_000,
});API failures throw RunCloudError. In addition to status and detail,
interaction failures preserve code, retryable, details, requestId,
action, and the accepted/completed timing fields when the API provides them.
Screenshot options also accept requestId; screenshot timeout, transport, and
invalid-image failures retain the same correlation fields with stable
screenshot_* codes. isSimulatorCapacityError(error) narrows a failed create
to the typed, retry-safe simulator_capacity_unavailable 503 response.
Accessibility trees
Read the current nested accessibility hierarchy from either active platform:
const tree = await cloud.ios.accessibilityTree(ios.id);
for (const root of tree.roots) {
console.log(root.role, root.label, root.states, root.children.length);
}SimulatorAccessibilitySnapshot has a versioned cross-platform role, label,
value, state, bounds, identifier, and child schema, plus discriminated iOS and
Android native fields. iOS coordinates use points; Android coordinates use
physical display pixels. Secure text values are always null.
Options accept timeoutMs (20 seconds by default) and signal. Reads are
scoped to the authenticated active session. Released, expired, wrong-platform,
and inaccessible sessions fail with a typed RunCloudError instead of reading
another device.
Simulator recordings
Both platform clients expose startRecording, listRecordings,
getRecording, stopRecording, and downloadRecording. Start accepts an
optional idempotency key. Status includes lifecycle events, actionable failure
metadata, and the retention deadline; download returns a validated MP4 without
exposing backing-storage credentials. Ready recordings remain available after
the simulator session is released.
Linux sandboxes
Firecracker microVMs. The default reservation is 0.125 vCPU / 128 MiB — CPU bursts above it when the host is uncontended, memory does not.
const sandbox = await cloud.sandboxes.create({
image: "runcloud/agent-base",
cpu: 4,
memory: 8192,
disk: 40,
});
const result = await cloud.sandboxes.exec(sandbox.id, "npm install && npm test", {
timeoutSeconds: 600,
onStdout: (chunk) => process.stdout.write(chunk),
onStderr: (chunk) => process.stderr.write(chunk),
});
console.log(result.exitCode, result.stdout);
await writeFile(
"sandbox-artifact.tar.gz",
await cloud.sandboxes.readFile(sandbox.id, "/tmp/artifact.tar.gz"),
);
await cloud.sandboxes.destroy(sandbox.id);Open a short-lived public URL for a service without making the sandbox persistent:
await cloud.sandboxes.exec(
sandbox.id,
"nohup npm run dev -- --host 0.0.0.0 >/tmp/dev.log 2>&1 &",
);
const tunnel = await cloud.sandboxes.openTunnel(sandbox.id, 3000, {
ttlSeconds: 900,
});
await fetch(tunnel.url);
await cloud.sandboxes.closeTunnel(sandbox.id, tunnel.id);Tunnel hostnames are random bearer capabilities. Do not write them to public
logs. They expire automatically and are also removed when the tunnel or
sandbox is deleted. Opening a tunnel does not modify idlePauseSeconds.
Active tunnels are limited to 8 per sandbox and 100 per organization.
Cloudflare KV is eventually consistent, so a URL may remain reachable briefly
after closeTunnel returns. Do not treat close as instantaneous global
revocation. Stop the service or destroy the sandbox when access must end
immediately.
Ask for the exact resources you need:
await cloud.sandboxes.create({ cpu: 2, memory: 4096, disk: 40 });exec takes a string (run via /bin/sh -c) or an argv array to skip the shell.
onStdout and onStderr receive bytes as they arrive over the authenticated
WebSocket. A non-zero exit code is returned, not thrown — only transport and API
errors raise RunCloudError.
Creates are retry-safe with an idempotency key; a repeat returns the original sandbox instead of spawning — and billing — a second one:
await cloud.sandboxes.create({ image: "runcloud/agent-base", idempotencyKey: jobId });A sandbox's name is a label you own: set it at create, change it later, and
filter on it. Pausing keeps the disk and memory and stops compute billing, so
naming plus resume is how you hand the same sandbox back to the same piece of
work instead of booting a new one:
const [existing] = await cloud.sandboxes.list({ name: `project-${projectId}` });
const sandbox = existing
? await cloud.sandboxes.resume(existing.id)
: await cloud.sandboxes.create({ name: `project-${projectId}` });
await cloud.sandboxes.rename(sandbox.id, `project-${projectId}-v2`);
await cloud.sandboxes.pause(sandbox.id);Names are not unique and mean nothing to the platform, so check state on a
match before using it. Renaming works in any state but destroyed.
Snapshot a sandbox and restore it later:
const snap = await cloud.sandboxes.snapshot(sandbox.id, { label: "deps-installed" });
const restored = await cloud.snapshots.restore(snap.id);Sandbox fields are returned in both the API's snake_case and camelCase
(milli_cpu and milliCpu are both present).
Read fleet history and per-sandbox resource metrics, or control a desktop-image sandbox through its short-lived viewer and computer-use endpoints:
const overview = await cloud.overview();
const history = await cloud.sandboxes.history({ range: "24h" });
const metrics = await cloud.sandboxes.metrics(sandbox.id, { range: "1h" });
const desktop = await cloud.sandboxes.desktop(sandbox.id, { viewOnly: true });
const screenshot = await cloud.sandboxes.computer(sandbox.id, {
action: "screenshot",
});Use credential() for a lightweight token/product-scope probe and account()
for the caller's full profile and organization memberships. An API key can
revoke itself:
const scope = await cloud.credential();
const profile = await cloud.account();
await cloud.revokeCurrentApiKey();Distributed tracing
Every SDK-owned HTTP, multipart, byte-download, SSE, and WebSocket transport
injects W3C trace context. An active OpenTelemetry context is propagated when
one exists; otherwise the SDK starts the request with a fresh sampled
traceparent. WebSocket exec carries the same context in both the upgrade
headers and its first command frame.
Applications that already use OpenTelemetry can install their preferred global propagator and start a span normally:
import { trace } from "@opentelemetry/api";
await trace.getTracer("worker").startActiveSpan("provision", async (span) => {
try {
await cloud.sandboxes.create({ name: "traceable-job" });
} finally {
span.end();
}
});The client reads RUN_CLOUD_API_KEY first, then RUN_CLOUD_API_TOKEN. Override
the API origin with RUN_CLOUD_API_URL or by passing new Client({ apiUrl }).
Compatibility adapters
Provider-shaped migration adapters ship in this same package:
| Provider | Import |
| --- | --- |
| Modal | @run-cloud/sdk/compat/modal |
| E2B | @run-cloud/sdk/compat/e2b |
| Daytona | @run-cloud/sdk/compat/daytona |
| Vercel Sandbox | @run-cloud/sdk/compat/vercel |
| Blaxel | @run-cloud/sdk/compat/blaxel |
| Fly Sprites | @run-cloud/sdk/compat/sprites |
| Cloudflare Sandbox | @run-cloud/sdk/compat/cloudflare |
| CodeSandbox | @run-cloud/sdk/compat/codesandbox |
| Fly Machines | @run-cloud/sdk/compat/fly |
| Remotion Lambda | @run-cloud/sdk/compat/remotion-lambda |
// import { Sandbox } from "@vercel/sandbox";
import { Sandbox } from "@run-cloud/sdk/compat/vercel";
const box = await Sandbox.create();
const result = await box.runCommand("node", ["-e", "console.log('hello')"]);
console.log(await result.stdout());
await box.stop();The adapters preserve core creation, lookup/listing, synchronous command
execution, pause/resume where the provider exposes it, and destruction.
Provider-specific features that do not have a public run.cloud equivalent throw
UnsupportedCompatibilityFeatureError with a migration hint. In particular,
public port capabilities and filesystem mutation are not silently emulated.
Remotion renders with customer-owned S3
run.cloud can replace the Lambda compute used for a Remotion render without
owning the output bucket. Supply a presigned S3-compatible PUT URL; the sandbox
renders the video, uploads it directly to that URL, and is destroyed when
render() returns.
import { Client } from "@run-cloud/sdk";
const cloud = new Client();
const result = await cloud.remotion.render({
serveUrl: "https://example.com/remotion-bundle",
composition: "ProductDemo",
remotionVersion: "4.0.507",
inputProps: { customer: "Acme" },
codec: "h264",
output: {
uploadUrl: presignedPutUrl,
downloadUrl: finalObjectUrl,
contentType: "video/mp4",
},
});
console.log(result.outputFile);remotionVersion selects Remotion's matching public runtime image and installs
that exact renderer during sandbox startup; all Remotion packages in a project
should use the same version. You can pass image instead to use a custom
runtime that already includes the renderer. Signed URLs are written to
a private guest file and are not included in command logs.
For repeated renders, create a version-pinned snapshot once and persist its ID:
const runtime = await cloud.remotion.createRuntimeSnapshot({
remotionVersion: "4.0.507",
});
const result = await cloud.remotion.render({
serveUrl: "https://example.com/remotion-bundle",
composition: "ProductDemo",
snapshotId: runtime.id,
codec: "h264",
output: { uploadUrl: presignedPutUrl, downloadUrl: finalObjectUrl },
});The snapshot is private to the caller's run.cloud organization. Create one per
Remotion version and delete obsolete snapshots with cloud.snapshots.delete().
For a smaller migration from @remotion/lambda/client, switch the import and
add s3Output; existing region and functionName fields may remain while
compute moves to run.cloud:
import {
getRenderProgress,
renderMediaOnLambda,
} from "@run-cloud/sdk/compat/remotion-lambda";
const render = await renderMediaOnLambda({
region: "us-east-1",
functionName: "remotion-render-4-0-507-mem2048mb-disk2048mb-240sec",
serveUrl: "https://example.com/remotion-bundle",
composition: "ProductDemo",
codec: "h264",
s3Output: {
bucketName: "customer-video-output",
uploadUrl: presignedPutUrl,
downloadUrl: finalObjectUrl,
},
});
const progress = await getRenderProgress({ renderId: render.renderId });The adapter derives the exact Remotion image version from the standard Lambda
function name. By default, concurrencyPerLambda controls Remotion workers
inside one sandbox.
To split one video across multiple sandboxes, add a customer-owned temporary
chunk location. concurrency chooses the target maximum sandbox count, while
framesPerLambda can derive it after the SDK selects the composition. Each
allocated sandbox renders one frame range. For a sufficiently long video,
concurrency: 8 creates up to eight sandboxes:
const render = await renderMediaOnLambda({
functionName: "remotion-render-4-0-507-mem2048mb-disk2048mb-240sec",
serveUrl: "https://example.com/remotion-bundle",
composition: "ProductDemo",
codec: "h264",
concurrency: 8,
concurrencyPerLambda: 1,
s3Output: {
bucketName: "customer-video-output",
uploadUrl: finalPresignedPutUrl,
downloadUrl: finalObjectUrl,
},
runCloud: {
distributed: {
createChunkOutput: async ({ renderId, index }) => {
const prefix = `remotion-chunks/${renderId}/${index}`;
return {
video: await presignTemporaryObject(`${prefix}.ts`),
audio: await presignTemporaryObject(`${prefix}.aac`),
};
},
},
},
});
console.log(render.sandboxCount); // exact number actually allocatedpresignTemporaryObject() returns {uploadUrl, downloadUrl, headers?,
contentType?}. Both URLs point to the same temporary object. Signed URLs stay
out of shell commands, and the temporary objects are used to produce the final
artifact in customer S3. Configure an S3 lifecycle rule for the temporary
prefix.
Chunks must be equal-sized except for the last one, so very short or awkwardly
divisible compositions can use fewer sandboxes than the target. run.cloud still
does not create or host an S3 bucket, deploy the Remotion site,
store render metadata, or add a queue/webhook layer. render.sandboxCount and
progress.runCloud.sandboxCount report the exact fan-out. deleteRender() or
cloud.remotion.cancel() destroys every sandbox associated with the render;
finite sandbox timeouts remain the cleanup backstop.
cloud.assets.upload(blob, options) verifies the uploaded size and MD5 before
returning a ready asset. Re-uploading the same bytes reuses the verified object.
cloud.assets.uploadDirect(blob, options) uses the API's buffered multipart
endpoint when a signed-storage transfer is not suitable, and
cloud.assets.download(id) returns the owned asset as a Uint8Array:
const asset = await cloud.assets.uploadDirect(blob, { filename: "app.tar.gz" });
await writeFile("app.tar.gz", await cloud.assets.download(asset.id));For multipart archives, cloud.assets.uploadBatch(parts, { uploadBatchId,
uploadRunId }) numbers the parts, records per-part transfer telemetry, and
limits direct storage transfers to six at a time.
cloud.ios.screenshot(sessionId) and cloud.android.screenshot(sessionId)
return PNG bytes as a Uint8Array. Save the
bytes directly with node:fs/promises.writeFile, attach them to test artifacts,
or pass them to an image-processing library.
cloud.ios.logs(sessionId, { tail: 200 }) and
cloud.android.logs(sessionId, { tail: 200 }) return bounded entries from the
current lease. Use the async iterable followLogs(sessionId, { signal }) on
either client to process new entries until the signal is aborted or the session
ends.
cloud.ios.uploadVideo(sessionId, videoBlob, { filename }) accepts MP4 and
QuickTime blobs and imports them into Photos. The video is stored as a
user-owned run.cloud asset and remains available through cloud.assets.
cloud.ios.uploadCameraVideo(...) and cloud.android.uploadCameraVideo(...)
accept MP4 and QuickTime blobs and loop them through the target app camera.
cloud.ios.uploadMicrophoneAudio(...) and
cloud.android.uploadMicrophoneAudio(...) accept AAC, M4A, MP3, MP4 audio,
and WAV and deliver decoded samples through the target app microphone. Pass
bundleId to name the target app. Both results contain the retained uploaded
asset; delete it through cloud.assets when it is no longer needed.
