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:readalone for a view-only embed, addinput:writeonly 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#
| Prop | Type | Description |
|---|---|---|
deviceId | string | The instance to render. |
streamUrl | string | Signed URL from dev.getSignedStreamUrl(). |
fit | "contain" | "cover" | "native" | How the video frame fits its container. |
interactive | boolean | Forward pointer, keyboard, and gesture input to the device. |
showChrome | boolean | Render the device bezel and status bar overlay. |
onReady | () => void | Fires once the WebRTC connection is established. |
onDisconnect | (reason: string) => void | Fires 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