In Native is now the future of mobile at Shopify, we explained why we're moving back to Swift and Kotlin, and in Helix we shared how agents rebuild screens through small checkpoints and strict gates. Both posts mention a CLI that lets an agent inspect the app's state and run actions without a simulator. This post is about the architecture behind it, which we call Reflex.
When we decided to rebuild our apps natively, we went back to the drawing board with a basic question: how should a mobile app be built when agents write most of the code? The biggest bottleneck we had was the feedback loop. An agent can write a screen in minutes, but proving that the screen works requires an emulator, a build, a tap script, and a lot of patience. We had invested in simulator tooling to speed that up and hit a ceiling on how fast it could get.
Reflex is what emerged. The app's logic runs headlessly, every screen exposes its state and actions through one small contract, and a CLI drives that contract on both platforms from the same test file. This unlocked extremely fast feedback loops and gave us a way to keep two native implementations in sync. Below is how it works, piece by piece, and what it would take to build the same thing for your own app.
An overview of Reflex
Reflex is an MVVM-based architecture that shapes the app into something an agent can build, test, and drive quickly, without rendering any UI. It rests on four pillars:
- A core module with no UI: Everything except the views lives in a module with no UI imports and no device-dependent code, so the app's logic can run headlessly on macOS or the JVM.
- A strict ViewModel contract: Every screen's ViewModel follows the same Elm-style shape: actions in, state out, one place where state changes.
- ViewModel introspection: Each ViewModel is exposed through a JSON contract: a name, its state, and the actions it accepts. That is what lets a process outside the app, like a CLI, read a screen and act on it.
- A CLI: A platform-agnostic command line tool that lets agents interact with the app through that contract, without launching a simulator or emulator. Because it's platform agnostic, we can write one test case that covers both platforms.
Together, these pillars give an agent a way to explore the app on its own, feature by feature, with a blazing fast feedback loop and without depending on a device. Because the same test file runs against iOS and Android, a feature can't ship on one platform without the other keeping up.
Here is what that looks like from the agent's side:
-
lsprints the current screen's name, its state as JSON, and the actions it accepts -
do Action [json]invokes one of them, exactly modeling a user interaction - The CLI can run against a headless core process or a real build, and either way it only talks to ViewModels, never to the rendered UI.
That's the whole user interface. There's no TUI, no colors, no spinners. Agents don't care, and the time we didn't spend on terminal polish went into the feedback loop itself.
Share the test, not the code
Two native apps means two implementations of every screen, and something has to keep them equivalent. Our answer became a principle we kept coming back to: share the test, not the code. The code is written twice, the tests are written once. It rests on two decisions.
- No shared code between platforms. Swift on iOS, Kotlin on Android, each using its own UI toolkit and concurrency model, with no cross-platform layer in between. With agents writing most of the code, a second native implementation costs far less than it used to. An abstraction layer still carries its full cost: it adds a maintenance surface, hides platform behavior you later need for performance work, and turns every platform upgrade into a two-step migration. Writing the same ViewModel twice is cheaper than maintaining a bridge between the two.
- Parity enforced by tests, not by code. If the code isn't shared, something else has to keep the platforms in sync. For us that's the test suite: every behavior test runs against both platforms, and a PR can't merge if it only passes on one. The test is the shared artifact.
The next question was what kind of tests. The usual answers each had a problem.
- Cross-platform end-to-end tests: One test covers both apps, and we still use them for a few specific flows. While we've achieved high stability with our own E2E framework, they're still not as fast as we'd like for agentic loops and CI checks. They also need a simulator or emulator, and debugging a failure means inspecting a video or screenshots.
- Native UI tests with XCUITest and Espresso. Faster and more reliable, but still tied to a simulator or emulator, and written once per platform, so every test is duplicated and passing on one platform says nothing about the other.
- Unit tests: Fast, but they test a mock of the app rather than the app. They'd be written twice, once per platform, and two suites drift just like two codebases do. Because they sit so close to the implementation, comparing the Swift suite to the Kotlin suite tells you very little about whether both apps cover the same journeys.
We landed in the middle, on something closer to grey box testing. The test doesn't see pixels and it doesn't see internals. It sees exactly what the UI sees: the ViewModel's state and the actions the ViewModel accepts. Everything underneath is the real thing (real reducers, real services, real GraphQL client, real navigation) with fixtures swapped in at the edges where the app would otherwise reach the network or the device. No mocking frameworks anywhere in the suite.
A contract built on first principles
Removing the UI from the equation
Let's use a small example to talk about the architecture at a high level: a simple Inventory Management app, where the user picks a product from a list, opens its detail screen, and adjusts the stock count. It's enough to show the shape of things; a lot of detail is left out on purpose. The code is Swift, but the Kotlin side has the same structure and reads almost the same.
Each platform's app is split into two modules, plus one for the CLI.
-
Core owns everything that isn't UI: ViewModels, services, navigation state, GraphQL, persistence logic, deep links. It's a plain Swift package on iOS and a plain JVM module on Android. No
UIKit, noandroid.*. - Mobile owns the UI and the platform integrations: SwiftUI/Compose views, Keychain, push, camera, the share sheet. Mobile depends on Core. Core knows nothing about Mobile.
- CLI depends on Core the same way Mobile does, but runs on macOS or the JVM instead of a phone. That's what lets it control the app without a screen.

Core never imports Mobile. Core declares protocols for everything it needs from the platform and receives the implementations through its constructor. It's plain dependency inversion with no DI framework. The mobile app and the CLI are simply hosts that construct Core. The CLI uses fixtures instead of real platform services.
This split gives us a few things:
- Navigation lives in Core. Pushing a screen is a state change on a navigator, so the CLI can walk the entire app without a single view being created. Deep links, too.
- Core runs anywhere the language runs. The Swift core builds on macOS (and, in principle, Linux); the Kotlin core is a pure JVM module. Android behavior tests need a JVM, not an emulator.
- Tests are fast because nothing renders. There's no layout pass, no animation, no waiting for a view to appear. An action is a function call followed by a JSON read. The bigger reason is headless execution: our 1,700+ behavior tests finish in about four minutes on CI, on both platforms.
- Fixtures are constructor arguments. Any state that normally comes from outside the process (a GraphQL response, a Keychain entry, biometrics availability, a feature flag) is provided to the constructor. Simulating an edge case means passing a different fixture, not patching a mock mid-test.
There are no third-party dependencies in Reflex itself: a base ViewModel class, one small protocol for introspection (next section), and the discipline to keep Core free of platform imports. Keeping that discipline is the hard part for humans. For agents, it's a pattern to repeat, and they're good at that.
The shape of the app
Inside Core, the app is a tree of plain objects. At the root sits the app object, which owns the network client, the current session, and persistence. Below it are navigators (Tab, Push, Modal) that each hold the list of routes currently on screen. At the leaves there is one ViewModel per screen. Nothing in this tree knows about UI.
Views live outside Core, in the Mobile module. A view binds to one ViewModel, reads its state, and calls send(action:). That is the whole relationship between a view and the rest of the app.
Introspection Kit is a separate module that defines the contract between ViewModels and the CLI. Each ViewModel gets a small extension that describes the ViewModel as a node: a name, its state as JSON, and the actions it accepts. The CLI and the test SDK talk only to that layer. The app itself never calls it.

The rest of this section goes through each of those pieces: the ViewModel, the introspection node, and how the nodes form a tree the CLI can walk.
ViewModels: action in, state out
Every screen is backed by a ViewModel whose shape is influenced by the Elm architecture. The pieces:
- State — everything the screen shows, owned by the ViewModel. The View reads it and nothing else.
-
Action — the public surface: everything a View (or the CLI) can ask the ViewModel to do. Expressed as an enum, dispatched through
send(action:). -
Message — the internal surface:
Actionis wrapped in.viewAction, it is mostly used for results coming back from services. Only the ViewModel sees these. -
update(state, message)— the reducer. The only place state ever changes. Every path converges here. - Services — network, storage, anything with a side effect. Protocols are provided in the constructor, making it easy for the CLI to swap in fixtures.
The View's entire contract is: read state, call send(action:). It never calls a method on the ViewModel and never writes state.
Here's the detail screen's ViewModel. It's pseudo-Swift: no concurrency annotations, no access control, nothing that isn't needed to show the shape.
Action is the public surface, Message is the internal one, update is the single writer. Because the shape is strict, an agent can repeat it correctly a few hundred times.
Introspection Kit: the ViewModel, seen from outside
The CLI needs a way to read a ViewModel and send it actions. We call that layer Introspection Kit, and the contract a ViewModel has to meet is one protocol:
A ViewModel exposes itself by returning a node from its +Introspection extension, which holds a reference back to the ViewModel. On Android it's the same idea with a small difference: Kotlin extensions can't make a type implement an interface, so the node is a separate InventoryDetailViewModelIntrospectionNode class next to the ViewModel. The app never calls any of this. Delete the file and the app builds and runs exactly the same; you've only lost the ability to drive it from a terminal.
The node has four parts:
-
name— a stable identifier for the screen. Tests use it to know where they are. -
state— the ViewModel's state, encoded as JSON. Every field a user could see. -
actions— one entry perActioncase: a title and a closure that callssend(action:). An action can take a parameter.SetQuantitydeclares an input type, and the CLI sends a JSON object that is decoded into that type before the closure runs. The@IntrospectionInputmacro generates the input's JSON schema from its properties, so the CLI can show what an action expects and reject a malformed payload before it reaches the ViewModel, without anyone maintaining that schema by hand. -
child— an optional link to the node below this one. Followingchildfrom the root is how the CLI finds the focused node, and how actions are propagated from parent to child nodes.
Writing this "by hand" (read: with agents) is a perfectly fine approach; agents are good at reproducing it. But because the node's shape is this strict, it can also be generated: a macro or annotation on the ViewModel is enough to produce the node above at build time.
Three rules:
-
If a user can see it, it's in
state. If a user can do it, it's inactions. The node is a plain JSON view of the ViewModel. -
The node never repeats UI logic. It lists every action, every time, with no conditionals. Whether
Decrementdoes anything at zero stock is the ViewModel's decision, made inupdate, in the same place it's made for a button tap. If you catch yourself writing an if in a node, the rule belongs in the ViewModel and the result belongs instate. -
Actions go through
send(action:), nothing else. The node never calls a method on the ViewModel or mutates state. The CLI can't do anything a View couldn't.
This contract isn't only for tests. We wanted the app to be agentic-first, and the same state-and-actions surface the CLI uses is what Sidekick, Shopify's AI assistant, uses to take actions inside the app. There's no separate integration layer for it. Anything an agent can do from the terminal, an agent inside the app can do too.
The tree
The introspection nodes mirror the object tree from the start of this section, one node per object, linked through child. The app root's node has the navigator as its child, and the navigator's node has the ViewModel of the top route as its child. The CLI always focuses on the deepest node in that chain, the same way a user only interacts with what's on top.
Containers can deliberately lend actions to the focused child. A stack navigator propagates goBack; a tab navigator propagates selectTab. A plain ViewModel never propagates anything. If you ask the CLI to run an action that isn't on the focused node or lent by an ancestor, it refuses and lists the actions that are available.
Transport
In the headless CLI, the core process reads newline-delimited JSON on stdin and writes it to stdout. In a real build, a tiny socket server does the same job over a port, and the socket talks to the same ViewModels the screen is bound to. That's why the simulator updates as you type commands: zero interaction with the rendered UI, just views observing state. The server is compiled out of release builds, so it doesn't exist in what we ship.
Our test trophy
Our test distribution ended up looking less like a pyramid and more like a trophy with a very wide middle:
- Unit tests for primitives and shared components: formatters, parsers, the base ViewModel, pagination helpers. Small and few.
- CLI behavior tests for everything else: business logic, validation rules, navigation, error handling, and complete user journeys. This is the bulk of our coverage, and it runs on both platforms from one file. These are also the tests behind Helix's behavior gate: a checkpoint doesn't move forward until its CLI tests pass on both platforms.
- A thin layer on top: a handful of live integration journeys and a performance suite that drives real builds through the same protocol.

A few things about the middle layer:
Zero network, enforced
A behavior session installs a guard that blocks every HTTP request in the host process and fails the test if one was attempted, even if the app caught the error and recovered. Every network call and every external dependency has to have a fixture. The upside is that the suite has no I/O and no flakiness; the price is that fixtures get big. A single journey can carry a dozen GraphQL responses, which is tedious for a human to maintain but fine for an agent.
Failures are readable
When a grey box test fails, the message clearly indicates which assertion failed. An agent can act on that without opening a debugger.
Tests read like journeys
We wrote a small test SDK for Introspection Kit on top of Bun's test runner. It speaks the same JSON protocol as the CLI, has no runtime dependencies, and adds two things on top of bun:test: precondition, which waits until the focused node is the one you expect, and do, which runs an action and waits for an expectation on the fresh state. Here's what a test for the inventory detail screen looks like (it runs unchanged on iOS and Android; the harness picks the platform):
Both helpers poll for a couple of seconds and then fail with the last state they saw, so there are no sleeps and no "wait for element" helpers. The test is a transcript of what a user did and what they saw.
You can make these as granular as you like, but we deliberately don't. With unit tests, one small test per case is what tells you exactly where something broke. Here the failure already carries that information: which step of the journey, which action, which state was expected and which was seen. Agents are good at reading that and finding the cause without a pinpointed test, so we fold the steps of a journey into one test and spend the savings on execution time and maintenance. Failure scenarios that need their own fixtures, like the save failure above, stay separate because the setup is different, not because the assertion is.

Here's how fast these test cases run. After a warm-up step, the test case above runs in a couple hundred milliseconds without a device on both platforms.

The same test on an iOS simulator takes around two seconds.

How about open sourcing it?
This article is the open source.
There's no library to publish because the useful part isn't our code. Introspection Kit is tied to our session model, our navigators, and our fixture formats, and none of that would transfer to another app. What transfers is Reflex itself, the architecture described above. A capable coding agent can implement it for your app, in your language, from this post, and the result will fit your codebase better than anything we could package.
What you need to bring Reflex to your app:
- Separate the platform from the core. One module with no UI imports, constructed with every platform capability passed in. If you can't build your core on macOS or the JVM, you're not there yet.
-
Make every ViewModel look the same. Action in, state out, one
update, services through the constructor. -
Add the introspection node as an extension. State as JSON, every action as a closure over
send(action:), no conditionals. Agents can generate these from the ViewModel. - Build a tree and focus the leaf. The app root and navigators are nodes too. Let containers lend a few actions downward. Everything else is off-limits, like anything off-screen is to a user.
- Fail on any network. Fixtures for everything. It's the only way the suite stays deterministic once it's large.
- Write skills, not docs. Teach your agents how to run the CLI, how to add a fixture, how to name a test. The CLI is only as good as the agent's ability to reach for it.
- Remove the human-only complexity. No TUI, no pretty output, no interactive wizards. This is a tool for agents; keep it simple and fast.
Reflex gave us a feedback loop that matches an agent's pace and one test suite that keeps two native apps in sync. Today that suite is the shared source of truth between our iOS and Android apps, agents write most of it, and the fastest way to find out what the app does is still to open a terminal and type ls.
FAQ
How valuable are these tests if you're not testing the UI?
Very. Most of what goes wrong in a screen goes wrong in its logic, and that's exactly what these tests cover. Checking that a button is wired to send(action:) is rarely where the bugs are. UI verification happens elsewhere in our flow: every Helix checkpoint goes through a visual review against the reference app before it can land (see the Helix post), and we keep screenshot tests for the few components where pixels matter. Add the fact that we use the app ourselves every day, so there's constant organic testing on top. Behavior tests on ViewModel state, plus those two layers, are how we decide a screen is ready to ship.
Why not unit tests plus UI tests with XCUITest and Espresso?
We covered unit tests and native UI tests above: both are written once per platform, and neither says anything about parity. What that comparison leaves out is exploration. Neither gives an agent a way to walk the app outside a pre-written test, which turned out to matter as much as the tests themselves. An agent that can ls its way through the app debugs faster and builds features faster. And the same surface is what makes the app agentic-first for Sidekick, which we would otherwise have had to bolt on.
What about Appium or agent-driven device automation?
We use those tools where they fit. Because we own the codebase, we could offer a grey box interface, so an agent reads state directly instead of inferring it from an accessibility tree or a screenshot. That saves the extra round trips an agent spends reading the screen. The bigger reason is headless execution: thousands of tests finish in a few minutes on CI. End-to-end tests at that scale would take hours to run every scenario and a lot of infrastructure to run them in parallel.
