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.

SettingDefaultnative-simWhy
AGENT_DEVICE_DAEMON_IDLE_TIMEOUT_MS5 min0The 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_MS5 min0Same, for the XCTest runner.
AGENT_DEVICE_LEASE_TTL_MS5 minjob capThe 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