iOS Simulator · GitHub Actions · your browser

Your app on a real
iOS Simulator
running on the cloud.

Unlimited free minutes on public repos
$0.062/min on private repos

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.

Read the docs npm i -g native-sim
$ 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.

  1. Local

    Commit and push your app

    Creates the GitHub repo if there isn't one. Your app goes up first, on its own.

  2. Local

    Then push the workflow, separately

    A second commit adds .github/workflows/native-sim.yml and 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.

  3. 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.

  4. Local

    Mint a session id and an access key

    24 random bytes. The key is generated here and never leaves your machine.

  5. 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.

  6. 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.

  7. 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.

  1. 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.

  2. A tunnel dialed from the inside

    cloudflared runs 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.

  3. 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.

  4. Input travels back over a WebSocket

    Taps and keystrokes reach simctl as 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.

CacheStream URLApp on screen
Cold~5 min~33 min
Warm~3 min7 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.

ROFI working on code

About Author

ROFI working on code

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.