Documentation
Using native-sim.
One command builds your app on a GitHub-hosted macOS runner and streams the running iOS Simulator to your browser. Everything below is copy-pasteable.
Install
npm install -g native-sim
Prerequisites
The gh CLI authenticated (gh auth login), Node 20+, and an
Expo project. Check everything at once:
native-sim doctor
Your first session
$ cd my-expo-app $ native-sim up --public › Preparing repository ✓ pushed to you/my-expo-app › Dispatching build ✓ run https://github.com/you/my-expo-app/actions/runs/1234567 ● Simulator is live https://calm-river-1234.trycloudflare.com/?k=…
--public matters: public repos get unlimited free Actions minutes.
Private repos bill macOS at 10×.
Recipes
Common ways to use it.
Iterate on a JS change
The native build is cached against @expo/fingerprint, which ignores your
application JS. Change JS only and the cached .app is reused with a fresh
bundle — minutes instead of half an hour.
native-sim up --public # warm cache: ~7 min to app on screen native-sim up --public --no-cache # force a clean native rebuild
Run a build you already have
Skips compiling entirely. The archive is uploaded to a draft release on your own repo and fetched by the runner with its own token — no third-party account.
native-sim up --app-file ./MyApp.app # upload + run native-sim upload ./MyApp.app # upload only native-sim up --app-release MyApp.app.tar.gz # run something already uploaded native-sim up --app https://…/build.tar.gz # any reachable URL (e.g. EAS)
It must be a simulator build — a device .ipa cannot run on a
simulator, and the workflow fails early with a clear message if you pass one.
Share a session
The printed URL carries a one-time key. Anyone holding it can drive the simulator, so treat it as a password — and never paste it anywhere GitHub makes public.
End a session
native-sim status # current session and its URL native-sim down # cancel the most recent native-sim down --all # cancel every run still in flight
Doing nothing is fine too — the session ends itself at --minutes. Cancelling
only frees a runner sooner.
Agent control
Setting up agent-device.
--agent runs an agent-device proxy next to
the simulator, reachable at <url>/agent-device on the same tunnel and
the same key as the stream. Your agent taps, scrolls and reads the accessibility tree
of an app running on a GitHub runner, while you watch the same session in a browser.
1. Install the client
npm install -g agent-device # 0.20.0 or newer
The runner installs the same version you have locally — native-sim reads
agent-device --version and pins it, because the client and the proxied daemon
should match.
2. Start a session with the agent surface
native-sim up --public --agent
It is opt-in: a plain native-sim up exposes no control surface at all. The
XCTest runner build adds a few minutes, but it happens after the stream URL is
published, so it delays agent-readiness and not the stream.
3. Connect
The CLI prints this with the URL and key already filled in:
agent-device connect proxy \ --daemon-base-url https://<tunnel>.trycloudflare.com/agent-device \ --daemon-auth-token <key>
4. Drive the app
$ agent-device devices --platform ios iPhone 17 Pro (ios simulator target=mobile) booted=true $ agent-device open com.your.app --platform ios --device "iPhone 17 Pro" Opened: com.your.app Session state: /Users/runner/.agent-device/sessions/proxy_adc-e8554d $ agent-device snapshot -i @e14 [button] "Home" [selected] @e15 [button] "Explore" $ agent-device press 'label="Explore"' --settle Tapped label="Explore" (244, 822) settled after 1003ms: +15 -10 (~5 unchanged) $ agent-device close && agent-device disconnect
That /Users/runner/ path is the giveaway — the session lives on the runner, and
those coordinates are real touch injections, not screenshot diffing.
Gotchas worth knowing before you hit them
connect succeeds against a dead daemon
It takes no device lease until open, so a broken session only surfaces on your
first real command as Remote daemon is unavailable. Check health first — and note
a proxy whose daemon is gone answers HTTP 200 with
{"ok":false}, so curl -f calls that a success.
close before disconnect
The client state directory holds the session's ownership credentials. Delete it mid-session
and the runner keeps the device claimed by an orphan; open then fails
DEVICE_IN_USE and --force does not cover device claims. Only a new
session recovers it.
fresh tunnel DNS lags
Quick-tunnel hostnames are created seconds before you get them, and a resolver that answers
NXDOMAIN may cache that. If DNS fails right after a session starts, wait and retry.
the first snapshot builds a runner
iOS snapshots need an XCTest runner. The workflow builds it during the session and caches it against the agent-device and Xcode versions — minutes cold, seconds on a hit.
# is the agent surface actually alive? curl -H "Authorization: Bearer <key>" "<url>/agent-device/health" # want: {"ok":true,...,"upstream":{"ok":true,...}}
What the workflow overrides, and why
Three agent-device defaults assume a developer laptop and are wrong for a single-tenant runner that is destroyed when the job ends. Nothing leaks by disabling them — the hold loop is the session.
| Setting | Default | native-sim | Why |
|---|---|---|---|
AGENT_DEVICE_DAEMON_IDLE_TIMEOUT_MS | 5 min | 0 | The daemon reaps itself 5 minutes after the last command, and health probes do not count as activity — so it is usually gone before the agent connects. |
AGENT_DEVICE_IOS_RUNNER_IDLE_STOP_MS | 5 min | 0 | Same, for the XCTest runner. |
AGENT_DEVICE_LEASE_TTL_MS | 5 min | job cap | The device lease taken by open expires, after which every command fails UNAUTHORIZED. |
The hold loop also supervises the daemon: any time /health is not
ok:true it restarts the proxy, at most once a minute. The session outlives any
single daemon rather than enumerating every way one can die.
Reference
Every command and flag.
Generated from native-sim --help at build time, so it always matches the
version you have installed. -h works too.
native-sim — stream an iOS Simulator of your Expo app from a GitHub Actions runner
USAGE
native-sim <command> [options]
COMMANDS
up Push, build on a macOS runner, and open the live simulator (default)
init Write .github/workflows/native-sim.yml and the auth gate
status Show the current session and its stream URL
down Cancel the run and tear the stream down (--all for every session)
doctor Check prerequisites
upload Upload a simulator build to this repo's release (--r2 for R2)
r2 Configure R2 credentials, for --r2 (--status to inspect)
turn Store TURN credentials as repo secrets, for --transport webrtc
OPTIONS (native-sim up)
--minutes <n> How long to hold the stream open default 30, max 350
--device <name> Simulator device default "iPhone 17 Pro"
--app-file <p> Upload a local .app/.tar.gz and run it (hosted on this repo)
--app-release <a> Run an asset already on this repo's native-sim-build release
--r2 Host --app-file on Cloudflare R2 instead (needs: native-sim r2)
--app <url> Install an already-built simulator .app from a URL
no source is pushed; the repo holds only the workflow
--mode <m> build | app | go default build
--scheme <name> Xcode scheme default: generated workspace name
--runner <label> Runner label default macos-26
--repo <name> Repo name when creating one
--transport <t> http | webrtc default http; webrtc is paid + no better
--codec <c> mjpeg | h264 default mjpeg; h264 is dead on GH runners
--max-dimension Cap captured px; 0 = native default 900; lower = snappier, softer
--fps <n> MJPEG frame rate default 30; serve-sim default is 60
--quality <n> MJPEG quality 0.05-1 default 0.7
--agent Also expose an agent-device proxy so a coding agent can
drive the simulator (prints the connect command)
--export Also download the built .app archive (--out <dir>)
--no-cache Force a full native rebuild (cache is on by default)
--public Create the repo public (unlimited free macOS minutes)
--message <msg> Commit message
--no-open Do not open the browser
EXAMPLES
native-sim up --public --minutes 45
native-sim up --public --agent # stream + agent-device control
native-sim up --mode go --device "iPhone 17"
native-sim up --app https://expo.dev/artifacts/eas/xxxx.tar.gz --public
native-sim up --app-file ./MyApp.app # hosted on your own repo
native-sim upload ./build/MyApp.app.tar.gz
native-sim down