Simulators

iOS simulators and Android emulators as API-controlled infrastructure, create, stream, tap, and tunnel real mobile testing tools.


Overview#

Sandal simulators are real iOS simulators and Android emulators, provisioned on demand and controlled entirely through the SDK. Each instance streams to your app at 60fps, exposes a full remote-control surface (tap, swipe, type, screenshot, accessibility tree), and can be wired into existing mobile automation via Appium or ADB, no VNC hacks, no screen-recording proxies, no flaky CI images.

Use simulators for:

  • Visual regression and end-to-end mobile testing in CI
  • Human-in-the-loop QA embedded directly in your product
  • Agent-driven mobile app testing and exploration
  • Live device previews for design/eng review

Every simulator instance is isolated, billed per active minute, and destroyed (or paused) the moment you're done with it.

Create an instance#

import { Sandal } from "@sandal/sdk";

const sandal = new Sandal({ apiKey });

const dev = await sandal.devices.create({
  kind: "ios-simulator", // or "android-emulator"
  os: "ios-18",          // optional pin; defaults to latest supported
});

console.log(dev.id, dev.status); // "booting" -> "ready"
from sandal import Sandal

sandal = Sandal(api_key=api_key)

dev = sandal.devices.create(kind="ios-simulator")

Devices boot from a warm pool where possible, so create typically resolves in well under a second. Once dev.status is "ready", the instance accepts input and is streaming.

List and delete#

const devices = await sandal.devices.list({ status: "ready" });

for (const d of devices) {
  console.log(d.id, d.kind, d.createdAt);
}

await sandal.devices.delete(dev.id);

Deleting an instance tears down the VM and revokes any tokens or stream URLs issued for it immediately. If you only need to stop billing without losing state, pause instead:

await dev.pause();
const resumed = await sandal.devices.resume(dev.id);

Scoped tokens and signed stream URLs#

Every instance gets its own credentials, never a shared API key. Mint a short-lived, per-instance token when you need to hand control to a browser tab, a CI job, or another service:

const token = await dev.createToken({
  scope: ["stream:read", "input:write"],
  ttl: "10m",
});

const streamUrl = await dev.getSignedStreamUrl({ token });
  • Tokens are DPoP-bound: a token stolen off the wire can't be replayed from a different client without the matching private key.
  • Scopes are additive and explicit, request stream:read alone for a view-only embed, add input:write only when the caller needs to interact.
  • Signed stream URLs expire with the token and are single-instance; they cannot be reused against any other device.

60fps streaming#

The stream is H.264 over WebRTC, targeting 60fps at native device resolution, with adaptive bitrate for constrained networks. Latency from tap to frame is typically under 50ms on a warm connection, which is the difference between "usable for automated testing" and "usable for a human tapping through a live app during a demo."

<RemoteControl />#

The React component wraps the stream, input capture, and connection lifecycle into a single embeddable element.

import { RemoteControl } from "@sandal/react";
import { useRef } from "react";

function SimulatorPane({ deviceId, streamUrl }: { deviceId: string; streamUrl: string }) {
  const ref = useRef<React.ElementRef<typeof RemoteControl>>(null);

  return (
    <RemoteControl
      ref={ref}
      deviceId={deviceId}
      streamUrl={streamUrl}
      fit="contain"            // "contain" | "cover" | "native"
      interactive               // enables pointer/keyboard forwarding into the device
      showChrome                // renders the simulator bezel + status bar
      onReady={() => console.log("stream connected")}
      onDisconnect={(reason) => console.warn("stream ended", reason)}
    />
  );
}

Props#

PropTypeDescription
deviceIdstringThe instance to render.
streamUrlstringSigned URL from dev.getSignedStreamUrl().
fit"contain" | "cover" | "native"How the video frame fits its container.
interactivebooleanForward pointer, keyboard, and gesture input to the device.
showChromebooleanRender the device bezel and status bar overlay.
onReady() => voidFires once the WebRTC connection is established.
onDisconnect(reason: string) => voidFires on stream teardown, with a machine-readable reason.

Ref methods#

ref.current?.tap(120, 640);
ref.current?.screenshot(); // -> Promise<Blob>
ref.current?.sendKeys("hello@sandal.dev");
ref.current?.rotate("landscape");
ref.current?.reconnect();

Direct control from the SDK#

You don't need the React component to drive a device, the same primitives are available server-side, which is what CI jobs and agents use.

await dev.tap(120, 640);
await dev.swipe({ from: [200, 800], to: [200, 200] });
await dev.type("hello@sandal.dev");

const png = await dev.screenshot();
const tree = await dev.accessibilityTree();

const recording = await dev.startRecording();
// ... drive the app ...
const clip = await recording.stop(); // -> { url, durationMs }
  • screenshot() returns a PNG buffer, full device resolution.
  • accessibilityTree() returns the native AX tree (iOS) or the UI Automator hierarchy (Android), the same structure Appium and XCTest read from, useful for asserting on labels without pixel-diffing.
  • startRecording() / recording.stop() produce an MP4 stored and returned as a signed URL, handy for attaching evidence to a failed CI run or a bug report.

Appium and ADB tunnels#

For teams with existing Appium suites or raw ADB workflows, Sandal exposes a tunnel rather than making you rewrite tests against a new protocol.

const tunnel = await dev.openTunnel({ protocol: "appium" }); // or "adb"
console.log(tunnel.endpoint); // wss://tunnel.sandal.dev/...

Point your existing WebDriver capabilities or adb connect at tunnel.endpoint. The tunnel is authenticated with the same DPoP-scoped, per-instance token model as the stream, each tunnel is bound to one device and one caller, and expires with its token.

from appium import webdriver

driver = webdriver.Remote(tunnel.endpoint, options=capabilities)

Pricing#

Simulators and emulators are billed at $0.015 per active minute. Active minutes accrue only while an instance is ready and not paused, a paused device costs nothing, and boot time from the warm pool is not charged separately.

Next steps#

  • Sandboxes, general-purpose compute for the rest of your agent's work
  • Runners, CI runners for Linux, Windows, and macOS
  • Code interpreter, stateful code execution for LLM tool calls