iOS Simulator · GitHub Actions · your browser
Your app on a real
iOS Simulator
running on the cloud.
native-sim pushes your React Native or Expo app to a GitHub-hosted macOS runner, builds it, and streams the running simulator back to your browser. On a public repo the minutes are free and unlimited.
$ cd my-expo-app $ native-sim up --public --minutes 45 › Preparing repository ✓ pushed to bidah/my-expo-app › Dispatching build ✓ run https://github.com/bidah/my-expo-app/actions/runs/1234567 ● Simulator is live https://calm-river-1234.trycloudflare.com/?k=…
The flow
What native-sim up does.
One command, but the order matters more than it looks — GitHub imposes constraints that shape every step.
-
Local
Commit and push your app
Creates the GitHub repo if there isn't one. Your app goes up first, on its own.
-
Local
Then push the workflow, separately
A second commit adds
.github/workflows/native-sim.ymland the auth gate.Why two pushes? GitHub does not scan the first push to a brand-new empty repo for workflow files. Ship the workflow in that push and Actions never registers it — the dispatch then fails with a confusing 404.
-
Local
Wait for Actions to register it
Polls until GitHub acknowledges the workflow, and nudges it with a fresh commit if it stalls. A workflow only becomes dispatchable once it exists on the default branch.
-
Local
Mint a session id and an access key
24 random bytes. The key is generated here and never leaves your machine.
-
Runner
Boot, build, install, launch
A macOS runner boots a simulator, restores the fingerprint-matched build cache or compiles, then installs and launches your app.
-
Runner
Publish the URL as a commit status
The tunnel opens and the runner writes its hostname to a commit status.
Why a commit status? It is the only GitHub surface readable while a job is still running. Logs, artifacts and step summaries only appear after the job ends — far too late for a live stream. The runner publishes the bare hostname only; on a public repo, statuses are world-readable.
-
Local
Poll, assemble, open
The CLI polls for that status, appends the key it generated in step 4, and opens your browser. The full link exists only on your machine.
Everything after that is the runner refusing to exit — a health-check loop holds the VM
open for --minutes, and the session ends when it stops.
How it works
No inbound ports. No tricks.
A GitHub runner has no public IP and nothing can connect to it. native-sim never needs to — three ordinary mechanisms are stacked instead.
-
A job that refuses to exit
The final step spends its life in a health-check loop. That loop is the hosting: while it spins, the VM exists and the simulator stays booted.
-
A tunnel dialed from the inside
cloudflaredruns in the job and opens an outbound connection to Cloudflare's edge. Your browser's request comes back down a pipe the runner already opened. -
One HTTP response that never ends
The simulator's GPU framebuffer is captured, JPEG-encoded, and written as
multipart/x-mixed-replace— a single response that simply never completes. -
Input travels back over a WebSocket
Taps and keystrokes reach
simctlas genuine touch events. An auth gate re-checks the key on the upgrade request, not just on page load.
Agent control
Let your agent drive it.
--agent runs an agent-device proxy beside
the simulator, on the same tunnel and the same key as the stream. Your coding 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.
$ native-sim up --public --agent $ agent-device connect proxy \ --daemon-base-url https://<tunnel>/agent-device \ --daemon-auth-token <key> $ agent-device open com.your.app --platform ios Opened: com.your.app Session state: /Users/runner/.agent-device/sessions/proxy_adc-e8554d $ agent-device press 'label="Explore"' --settle Tapped label="Explore" (244, 822) settled after 1003ms: +15 -10 (~5 unchanged)
That /Users/runner/ path is the giveaway — the session lives on the runner, and
the coordinates are real touch injections, not screenshot diffing.
Build caching
The build is the whole cost.
A cold native build measured 28.4 minutes. native-sim caches the built
.app keyed on @expo/fingerprint, which hashes only what affects the
native build and deliberately ignores your JavaScript. Change JS, get a cache hit:
the cached native shell is re-bundled with your current code.
| Cache | Stream URL | App on screen |
|---|---|---|
| Cold | ~5 min | ~33 min |
| Warm | ~3 min | 7 min (measured) |
Already have a simulator build? --app-file skips compiling entirely, and your
source is never pushed — the repo holds only the workflow.
Build hosting
Your builds stay on your repo.
To run a prebuilt app, the runner has to fetch it from somewhere. native-sim uploads it as
an asset on a draft release of the same repo the workflow runs in — so the job's own
GITHUB_TOKEN can already read it.
$ native-sim up --app-file ./MyApp.app › Uploading ./MyApp.app to you/your-app ✓ uploaded 26.4 MB as MyApp.app.tar.gz › draft release on this repo — only people who can read the repo can fetch it ● Simulator is live
No third-party account
No bucket, no API token, no credentials file. If you can push to the repo, hosting already works.
Private repos stay private
A private repo's release assets are readable only by people who can read the repo — at no cost, and with no signed URL to expire mid-session.
Draft by default
Draft releases are not listed publicly, so a public repo does not quietly start publishing your binaries. You are warned if one ever would.
Cost
Free on public repos.
Public
Unlimited free Actions minutes. This is the whole reason native-sim is viable — --public is not a detail.
Private
macOS bills at $0.062/min with a 10× multiplier against included minutes. One hour is 600 quota minutes. native-sim warns you.
Access
Every session gets a one-time key. The gate requires it on each request and on the WebSocket upgrade — but it is a shareable bearer link.
Limits
What it is not.
Ephemeral
The VM is destroyed when the job ends. Every session gets a new URL and an old link can never be revived.
Six hours, hard
GitHub kills hosted jobs at 360 minutes. The hold loop ends the session earlier.
A small box
~3 vCPU and 7 GB running Xcode, a simulator and a video encoder. A warm cache keeps the stream smooth.
Within reason
Actions terms cover building and testing the repo's software. PR-preview sessions fit; a 24/7 simulator host does not.
About Author
Hello there 👋, Rodrigo Figueroa here.
I have been working with React Native and Universal apps for more than 7 years now. Over that time I have helped engineer and code projects in companies and AI labs such as The AGI Company, Rally RD and Tamagui, plus various OSS repositories including: Uni Stack: Expo Router universal starter kit, universal React Native ecommerce with Universal Medusa (and it's companion Mobile Medusa), and also built the first compatibility layer for Vercel AI SDK to work on React Native with the react-native-vercel-ai library.
The agent tooling that has arrived in the last year makes this more useful than it would have been even a year ago — an agent can now drive the running app, read its accessibility tree and verify its own changes against a real device runtime. My goal with this project is to make that loop available to you, your startup or company without a Mac, a device lab, or a per-seat cloud bill.
Because I think simulators should be a piece of the ecosystem that is open. We should not stop at thinking that only the framework should be open — the infrastructure you need to actually run and see what you build is just as much a part of developing with React Native.
Why? This is a stance, and a gift from me to the community: a future where the infrastructure for React Native development is more accessible for everyone.