The Duet Framework: One Shared Core, Two Native Apps
The Duet framework: feature logic written once, native SwiftUI and Jetpack Compose interfaces, and a CI gate that fails when the two apps behave differently.
Duet is the architecture Modaal scaffolds when you pick one of the two Duet cards in the new-project wizard: iPhone and Android together or iPhone now, Android later. Each feature’s logic — its state, the actions that change it, the effects it requests — is written once. The iPhone app renders that state in SwiftUI; the Android app renders it in Jetpack Compose. Both apps replay the same recorded fixture files, and the build fails when the two replays differ.There is no shared UI toolkit and no web view. Both apps are ordinary native codebases you can open in Xcode and Android Studio, ship to the App Store and Google Play, and export at any time.
Status. Android support is available to every Modaal account, in open beta. Modaal Free builds one platform of your choice; building iOS and Android together is part of Pro. The product page is modaal.dev/duet.
One pure reducer per feature holds the behavior. Adding a rule, fixing a bug, or changing a flow is one edit that both apps pick up, rather than the same change applied twice in two languages.
Fully native interfaces
SwiftUI on iPhone and iPad, Jetpack Compose on Android. Each app follows its own platform’s conventions — Apple’s Human Interface Guidelines on one side, Material 3 on the other.
Parity is a build gate
Behavior is recorded as fixture files. Both platforms replay the same fixtures and the gate is byte equality, so a divergence fails the build on the commit that caused it.
Android is added feature by feature
Start iPhone-only and add Android per feature later. The fixtures recorded from the Swift reducer are what the Kotlin one is verified against.
How Duet compares to other cross-platform approaches
Flutter, React Native
Compose Multiplatform
Two separate codebases
Duet
Which layer draws the screen
One toolkit renders on both platforms; the widget set belongs to the framework
Compose renders on both platforms
Each platform’s native toolkit
Each platform’s native toolkit — SwiftUI and Compose
Where feature logic lives
Once, in Dart or JavaScript
Once, in Kotlin
Twice, once per platform
Once, in a pure reducer per feature
What proves the two platforms agree
Nothing separate — there is one implementation
Nothing separate — there is one implementation
Manual QA on both apps
Recorded fixtures replayed on both platforms; CI compares byte-for-byte
A native, perf-heavy island
An escape through the framework’s bridge — platform view, texture or native component, with SwiftUI and Compose reachable only as wrapped views inside it — written per platform in Swift/Objective-C(++) and Kotlin, compiled by Xcode and Gradle in the app build, with its logic outside the shared code
Shared when the framework can draw it; on iOS a native island is a UIKitView built in Kotlin through Objective-C interop, or in Swift for Swift-only APIs and SwiftUI views, inside a framework-drawn screen
Ordinary platform code, written twice and compiled by Xcode and Gradle
Ordinary platform code, written twice and compiled by Xcode and Gradle; the reducer decides, a worker runs the pipeline, and the seam is recorded
Each column is argued in full on its own page: the framework survey places every mainstream option on these axes, and the pairwise pages cover Flutter, React Native and Compose Multiplatform.The trade Duet makes is explicit: you write the views twice and the behavior once. Screens, navigation chrome, animations and controls are authored per platform, which is what keeps each app native. Everything a fixture can see — state shape, the actions that change it, the effects a reducer requests, the order they come back in — is shared and gated.
The agent writes both view layers. The practical cost of “twice” is agent time on the second set of screens; the behavior underneath is already written, recorded and passing, so there is no second design pass.
On the Modaal home screen, type your idea into the composer and press Start new project. You can attach a PRD, screenshots, a design doc, or a Figma link.Nothing is written to disk yet — the button opens the two-step wizard, and cancelling at any point creates no project, no folder and no git repository.
The wizard’s first step shows the template cards two to a row. The first row holds the two Duet cards:
The first row of the wizard — the two Duet cards. 'iPhone and Android together' is preselected.
Card
Outcome line
What it creates
Architecture line
iPhone now, Android later
Ship iOS first, add Android without a rewrite
One iPhone app
Duet architecture (Swift)
iPhone and Android together
One codebase, both stores
An iPhone app and an Android app
Duet architecture (Kotlin Multiplatform)
iPhone and Android together carries the Default chip — it is the card selected when the wizard opens. On a plan that does not include building iOS and Android together, that card is locked and Default moves to iPhone now, Android later. The question mark beside the architecture line opens the template section of Starting a new project.The rows below hold the iPhone-only templates (Quick prototype, 2D game / Interactive app) and the Apple Watch ones. The wizard no longer offers Production app, the CombineRIBs template, because a Duet card covers every app it scaffolds; projects already on it are fully supported and keep getting features. The exception is a game — 2D game / Interactive app is the only card that scaffolds a SpriteKit scene host, so pick it for a game and take the CombineRIBs architecture with it.Starting a new project shows the whole step in one screenshot and covers every card the wizard offers.
Step 2 collects what Modaal needs before it can generate the project. Duet projects get one extra control the iOS-only templates do not have:
Field
What it is
Product name
Your app’s name, prefilled from your description. Anything you type is never overwritten by a late suggestion.
Organization identifier
Reverse-DNS prefix, e.g. com.acme. Internal only: its only use is to supply the first part of the bundle identifier. See How the three fields fit together.
Bundle identifier
Derived as <organization identifier>.<product name without spaces>. You can override the suffix. This is the ID registered with App Store Connect and TestFlight.
Devices
On iPhone and Android together: iPhone, iPad and Android, with iPhone and Android checked. Android here is a target toggle — unchecking it creates the project without the Android app; unchecking both iPhone and iPad creates it without the iOS app. Ask the agent to add the missing one later. On iPhone now, Android later: iPhone and iPad, with iPhone checked.
iPhone / iPad orientations
Portrait, Upside Down, Landscape Left, Landscape Right, per device family.
Android orientations
Portrait and Landscape. Both checked — the default — lets the app rotate freely. Portrait alone locks it to portrait; Landscape alone allows both landscape rotations.
Replace the placeholder com.example with your own organization identifier. Once a build reaches TestFlight or the App Store, its Bundle ID can never be changed. Modaal blocks Create while the placeholder is still there.
A folder, a project record and a git repository, all under the name you entered. The final name is used from the first write, so there is no rename part-way through.
2
The Duet template is scaffolded
The app targets, the shared core, the parity tooling, the theming and control libraries, a CI workflow, and a test suite that passes on the first run. Any files you attached are copied into the project.
3
Your original prompt is sent as the first message
The agent receives it with Plan mode on and continues from where the wizard finished.
The wizard has already fixed the structure, so the agent’s first turn starts at product scope. It reads your description and attachments, writes a structured PRD.md at the project root, then plans a workable first iteration into specs/001-<feature>/spec.md.It never asks which architecture to use — the template and the per-target architecture are recorded in .modaal/project.json and read on every turn.When the plan looks right, turn Plan off and say “let’s build this”. See Modes and prompts for how the Plan toggle works from there.
Both cards scaffold the same architecture, the same authoring loop and the same gates. They differ in how many app targets exist on day one, and in which language holds the feature logic.
iPhone and Android together: the Kotlin Multiplatform flavor
Template id:duet-kmp · Emits: an iPhone app and an Android app · Feature logic: Kotlin commonMainFeature logic is one Kotlin module per feature under src-kmp/. The Android app consumes it directly. The iPhone app consumes the same compiled logic through a Kotlin/Native framework — there is no second implementation of a reducer to keep in step, and no hand-written Swift copy of any Kotlin rule.
src-kmp/ # the shared Kotlin core├── <feature>/ # one module per feature: State, Action, Effect, reducer├── services/ # cross-cutting ports, log vocabulary, consent state├── telemetry/ # the closed analytics event grammar├── theming/ # design tokens, shared by both apps├── apple-umbrella/ # one Kotlin/Native framework aggregating every feature├── replay-runner/ # replays fixtures on the JVM└── app/ # the Android app: Compose shell, workers, Activity edgesrc-ios/├── App/ # the iPhone app target; xcodegen.yml is the source└── Libraries/ # Kit (links the framework), Main (composition root), # Theming, ThemedControlsparity/├── manifest.yaml # the declared features, fixtures and generators├── authoring.md # this repo's own front door for feature work├── feature-specs/ # one one-pager per feature└── fixtures/ # recorded behavior — build products, never hand-editedtools/duet # the CLI that records and verifies.github/workflows/ # the parity gates, running on every push
Pick it when Android is in scope now, when you want both stores at launch, or when you would rather take on the Kotlin core now than migrate to it later. What it needs: the Android SDK and a JDK on your Mac, alongside Xcode. Modaal checks for both and shows an Action needed: install the Android SDK banner with a button that installs what is missing — Set up the Android toolchain documents the whole flow.
Template id:duet-swift-ios · Emits: one iPhone app · Feature logic: Swift packagesFeature logic is one pair of Swift packages per feature under src-ios/Subtrees/<Name>/ — the gated half holding the reducer, its types and its scenario tests, and the app-facing half holding the builder and the SwiftUI shell. Pure SwiftPM: no Gradle, no Kotlin, no Android SDK needed to build or ship it.
src-ios/├── Subtrees/<Name>/ # one directory per feature:│ ├── <Name>Feature/ # the reducer, its types, the worker, the scenarios│ └── <Name>Node/ # the builder and the SwiftUI shell├── App/ # the iPhone app target└── Libraries/ # Main (composition root), Theming, ThemedControlsparity/├── manifest.yaml├── authoring.md├── feature-specs/ # the porting source of truth└── fixtures/ # recorded behavior — build products, never hand-editedtools/duet.github/workflows/
This flavor keeps the one practice an iOS-only project would normally skip: every feature is recorded and specced exactly as in the two-platform flavor. The fixture corpus is the portable spec of each feature’s behavior, and parity/feature-specs/<name>.md is the porting source of truth. Both are in place from the first feature, before there is an Android app to port to.Pick it when Android is a “probably, later”, when you want the smallest tree that still converges, or when you want the iPhone app in users’ hands before taking on a second toolchain. What it needs: Xcode only.
Android is in scope now, or you want both stores at launch
iPhone and Android together
Android is a “probably, later” and the iPhone app ships first
iPhone now, Android later
You want one toolchain on your machine for now
iPhone now, Android later
You are certain the product is iPhone-only forever
Either card works; iPhone now, Android later is the smaller tree
You are building a game or a drawn playfield
2D game / Interactive app — no Duet card scaffolds a SpriteKit scene host
You are validating a throwaway idea
Quick prototype — note that an MVVM project has no migration path to Duet
Pick a Duet card at creation time if Android is anywhere in the plan. The architecture a template fixes holds for the life of the project. A CombineRIBs project can be carried to Duet feature by feature, but an MVVM one cannot — going to production from Quick prototype means a second project, written again.
The Swift flavor converges on the Kotlin Multiplatform flavor. Android arrives as a per-feature, fixture-gated migration into the same repository, not a second tree kept in step by hand.
1
The Kotlin core and the Android app shell are added to the repo
The Gradle plane, the shared-core modules, the Android app shell and the Kotlin side of the parity workspace land in the existing tree. The iPhone app builds, runs and ships unchanged while they sit alongside it.
2
Features cross one at a time
Per feature: the Kotlin reducer is written against the fixtures already recorded from the Swift one, the Android app mounts the feature, and the iPhone app moves onto the shared core for it. The gate is that the same fixture bytes replay on the new side.
3
The manifest records how far you are
parity/manifest.yaml declares the ported side per feature, so a half-migrated repository is an ordinary, shippable state. You can stop between any two features and keep the work finished so far.
This is the same shape as the CombineRIBs → Duet migration an existing Production app project takes, with several of its steps already done:
Migration step
From CombineRIBs
From the Duet Swift flavor
Readiness audit — find the untested logic and the shapes that resist conversion
Required first
Not needed; every feature is already a pure reducer
”Author new features in the target shape from today”
A rule to adopt, plus a sweep of the repo’s agent documents
Already how the repo works
Per feature: write the reducer, its spec and its fixtures
The bulk of the work — and it starts by discovering what the old code actually did
Already done, and recorded
Per feature: write the Kotlin twin
Against fixtures recorded during the same migration
Against fixtures recorded when the feature was first built
Retire the old architecture — coexistence packages, shell swap
A terminal phase of its own
Nothing to retire
What is left is adding the Kotlin core to the repository and translating one feature at a time: the Kotlin reducer has an executable specification to satisfy before its first line is written, which bounds the work.
You do not schedule this from a menu. Ask the agent for Android when you want it, and it works through the steps above with you, one feature at a time.
This is the loop the agent runs for every behavior change, in either flavor, and the one you run yourself if you work in the repo directly:
1
Write the feature spec
A one-pager under parity/feature-specs/<name>.md: the states, the transitions, and a row per ending the feature can reach.
2
Write the scenario
Given / When / Then — a branch per ending. This is the executable form of the spec.
3
Record the fixtures
tools/duet record --feature <name> compiles the scenario into fixture files. Fixtures are build products: they are never edited by hand, and where a fixture and the prose disagree, the fixture is authoritative.
4
Implement the reducer
Adjust the pure reducer until the scenario passes. The reducer takes no environment — no clocks, no IDs, no network — so anything nondeterministic round-trips through an effect and comes back as an action. That is what makes the recording replay identically on both platforms.
5
Wire the shells
A SwiftUI screen on iOS, a Compose screen on Android. Each one reads state and sends actions; neither makes a navigation decision of its own.
6
Verify
tools/duet verify — the same gate CI runs. It replays every fixture on every platform and compares byte-for-byte.
You do not have to run these commands: the agent runs them, and the CI workflow in the scaffold runs them again on every push. They are in your repository and documented in parity/authoring.md, so you can run them yourself at any time.
To run this loop by hand from an empty tree to the CI gate, follow the Duet tutorials: nine tutorials build one app, Foyer, on both platforms, each opening a starting tree and ending with a finished tree you can compare against, all in the public duet-tutorials repository. Tutorial 1 runs the spec, scenario, record, reducer and verify steps on one feature with no UI; Tutorial 2 wires the first shells; Tutorial 6 puts verify and the checks around it into a CI workflow.
Views are written twice. Two screen implementations per feature, one per toolkit. The logic, the navigation decisions, the effect handling and the test corpus are written once.
The architecture is fixed at creation. Duet projects stay Duet projects. Modaal has no command that converts a target from one architecture to another; the migrations that exist run into Duet, feature by feature.
No game template. No Duet card scaffolds a SpriteKit scene host. A game starts from 2D game / Interactive app, which is CombineRIBs.
Building both platforms is a Pro feature. Free builds one platform of your choice. See Plans & pricing.
You still need a Mac. Modaal builds and runs through Xcode, so macOS with Xcode 26 or newer is required for the iOS half. See Set up Xcode.
Duet is pre-release. The framework, both flavors and the toolchain have landed and the contracts are versioned alongside the code, but no artifacts are published yet. Treat the API surface as a preview.
The iPhone and Android together card is built on Kotlin Multiplatform: the shared core is Kotlin commonMain, and the iPhone app consumes it through a Kotlin/Native framework. What Duet adds on top is the part KMP leaves to you — the feature shape, the recorded fixtures, and the CI gate that compares the two platforms’ behavior. The iPhone now, Android later card is pure Swift with no Kotlin or Gradle in the tree.
Is the UI shared?
No. There is no cross-platform UI layer. iOS renders SwiftUI, Android renders Jetpack Compose, and there is no shared UI code between them. What is shared is what is on screen, expressed as a value in feature state; what stays per platform is how that value is rendered. Handling platform-specific UI is the full treatment of where that line sits, and Tutorial 5 shows it on one screen: the same upgrade flow presented as a sheet on iOS and a bottom sheet on Android from one state value.
How does Duet handle lifecycle differences between SwiftUI and Compose?
Each app keeps its own platform lifecycle, and no lifecycle callback makes a decision. Three rules keep lifecycle handling out of the shared logic:
Stores live outside the view tree. On Android the app’s single Activity keeps the root of the store tree in a retained object, RetainedRoot, held by Essenty’s InstanceKeeper. A rotation recreates the Activity and the composition, and getOrCreate returns the same root with every store still running. Finishing the Activity is the only teardown. On iOS the scene delegate holds the root and tears it down in sceneDidDisconnect.
Views report what happened, and reducers decide. A composable or a SwiftUI view sends actions such as Appeared. Compose re-runs its effects after a rotation, and SwiftUI calls onAppear again when a tab is shown again, so a reducer that receives Appeared ignores a repeat. That guard is part of the recorded behavior both platforms replay.
Navigation is state, so restoring it means restoring data. Before the process can be killed, each app saves the route spine: every level’s route value, encoded by one Kotlin function. Android writes it in onSaveInstanceState and iOS in the scene’s stateRestorationActivity. On relaunch the tree is rebuilt with the spine as each store’s initial state. Back presses, sheet dismissals and deep links also enter the shared logic as actions.
MainActivity.kt (simplified)
class MainActivity : ComponentActivity() { private lateinit var retained: RetainedRoot<RootMount> override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) // Null on a fresh start and after a rotation; the saved spine after process death. val restored = decodeRouteSpine(savedInstanceState?.getString(SPINE_KEY)) // A rotation returns the same instance, with every store still running. retained = instanceKeeper().getOrCreate { RetainedRoot(Dispatchers.Main.immediate, RootMount::teardown) { scope -> RootBuilder(dependency).buildRoot(scope, restored) } } setContent { AppRoot(retained.component) } } override fun onSaveInstanceState(outState: Bundle) { super.onSaveInstanceState(outState) outState.putString(SPINE_KEY, encodeRouteSpine(retained.component.routeSpine())) }}
SceneDelegate.swift (simplified)
func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options: UIScene.ConnectionOptions) { let saved = session.stateRestorationActivity?.userInfo?[spineKey] as? String let root = RootBuilder(dependency: SceneComponent()).buildRoot(restored: decodeRouteSpine(text: saved)) window.rootViewController = UIHostingController(rootView: RootView(shell: root.shell)) window.makeKeyAndVisible() root.shell.activate() // mounts the first child once the window is visible self.root = root}func stateRestorationActivity(for scene: UIScene) -> NSUserActivity? { let activity = NSUserActivity(activityType: spineActivityType) // The same Kotlin encoder the Android app calls. activity.addUserInfoEntries(from: [spineKey: encodeRouteSpine(spine: root.shell.routeSpine())]) return activity}func sceneDidDisconnect(_ scene: UIScene) { root?.shell.deactivate() // dropping the reference alone cancels nothing on the Kotlin side root = nil}
SplashFeature.kt (commonMain, simplified)
SplashAction.Appeared -> if (state.isArmed) Reduced(state) // a repeated Appeared writes nothing and emits nothing else Reduced(state.copy(isArmed = true), listOf(Effect.Run(ArmSafetyNet(SAFETY_NET_MILLIS))))
The recordings check the reducers, including these guards. The recordings do not check the Activity, the scene delegate or the views; host tests do. RootFlowTest on the JVM and RootCompositionSpec in Swift each build the tree, tear it down, and rebuild it from a saved spine. Tutorial 2 writes the retained root, Tutorial 5 adds the route spine and back handling on Android, and Inside a Duet Android app walks through the Activity of a shipped app.
Does a parent reducer know its children's state?
No. Every feature has its own store with its own state. A parent reducer knows two things about a child: which child is mounted, stored as a field in the parent’s own state, and the child’s delegate event type, wrapped in one of the parent’s actions. It never holds a child’s state and never sends a child’s actions.
RootFeature.kt and SignInFeature.kt (commonMain, simplified)
data class RootState( val phase: RootPhase = RootPhase.Splash, // which child is mounted val entitlement: Entitlement = Entitlement.Free, // the root's own data) // no SignInState, no MainStatesealed interface RootAction { data class SignIn(val event: SignInDelegateEvent) : RootAction // the child's outward events only data class Main(val event: MainDelegateEvent) : RootAction data class EntitlementChanged(val entitlement: Entitlement) : RootAction}// In the child: the event is returned as effect data, so the child's recording contains it.is SignInOutcome.SignedIn -> Reduced(state.copy(isSigningIn = false), listOf(Effect.Run(NotifyHost(Completed(displayName)))))
The stores are connected in the app module, by one Builder per level. A ChildSlot builds the child the parent’s state names and tears down the previous one, and composables render whichever child is mounted:
RootBuilder.kt, MainBuilder.kt and AppRoot.kt (Android app module, simplified)
val slot = host.adopt(ChildSlot<RootPhase, RootChildMount>( build = { phase -> when (phase) { RootPhase.SignIn -> RootChildMount.SignIn( SignInBuilder(component).buildSignIn(onDelegate = { rootStore.send(RootAction.SignIn(it)) })) RootPhase.Main -> RootChildMount.Main( MainBuilder(component).buildMain(displayName = rootStore.state.value.auth.displayNameOrGuest, entitlement = mount.entitlement)) // … } }, teardown = { it.teardown() },))host.adopt(StateTransitions(scope, rootStore.state) { _, state -> mount.project(state.entitlement) // publish the one value the tabs read slot.reconcile(state.phase) // build the named child, tear down the previous one mount.publish(slot.activeHandle) // the child the composables render})// MainBuilder: the value reaches the tab as the tab's own action.host.adopt(StateTransitions(scope, entitlement) { _, value -> homeStore.send(HomeAction.EntitlementChanged(value))})// AppRoot: collects the mounted child and renders its screen over the child's own store.@Composablefun AppRoot(root: RootMount) { when (val child = root.child.collectAsState().value) { is RootChildMount.SignIn -> SignInScreen(child.store) is RootChildMount.Main -> MainScreen(child.mount) // … }}
Values move between levels in four ways:
Up: a child’s delegate event, received by the parent as one of its actions.
Down, once: a seed value passed to the child’s Builder when the child is mounted, such as the display name above.
Down, while mounted: a slice. The parent publishes one value from its state, and the child receives each change as an action of its own. The child reads the value and never writes it back.
Between siblings:lateral state, a worker owned by the siblings’ nearest common parent.
On Android the Activity holds only the root of this tree. Unlike a per-screen ViewModel, which is scoped to a back-stack entry, a child store exists while its parent’s state names it. Tutorial 3 builds the tree on both platforms, and Tutorial 4 adds the slice.
Can I ship an App Store app from the Swift flavor?
Yes. iPhone now, Android later emits a complete iPhone app — an XcodeGen-generated Xcode project, build configurations, an app icon, a localization catalog and a passing test suite. It is a shipping iOS app that also keeps a portable record of its own behavior.
What happens when one platform can't do what the other does?
The shared reducer models the unavailable path as a failure, and the project’s parity/manifest.yaml records the divergence. Apple sign-in has no Android counterpart: the Android environment maps that path to a failure the reducer already handles, and the Android UI has no Apple sign-in button. The shared half needs no contract change, because it never assumed the button was there.
Can I open the project in Xcode and Android Studio?
Yes. The output is native codebases in one git repository — an XcodeGen-generated Xcode project on one side, a Gradle project on the other. Open them, edit them, run them, ship them. Modaal adds no proprietary format and no export step.
Can I add Android to a project that isn't on a Duet card?
A Production app (CombineRIBs) project has a documented migration path into the Duet shape, taken feature by feature — Migrating a CombineRIBs app to Duet is the walkthrough. A Quick prototype (MVVM) project has no migration path — Android there means a new project.
Does the shared core cover networking and storage too?
The reducer decides; workers do the work. Network calls, disk access, camera sessions and timers live in workers behind interfaces the reducer names as effects, and each platform supplies its own implementation. The shared half is the decisions and the ordering; the platform half is the I/O. Tutorial 4 writes the first two workers and the on-device backend they observe.
Nine hands-on tutorials build one native iOS and Android app, Foyer, from a first Kotlin feature to the CI checks, theming, localization and analytics.
Handling platform-specific UI
The technical deep dive: the five-layer presentation contract, the kind→renderer registry, and how deliberate iOS/Android divergence is recorded.
Starting a new project
The full wizard walkthrough and every template Modaal offers, Duet and otherwise.
Migrating a CombineRIBs app
Already on the Production app card? The per-feature route from CombineRIBs to a shared Kotlin core and an Android app.
Should you share code between iOS and Android?
The decision upstream of Duet: write twice, share everything, or share the logic — with costs and returns.
The best cross-platform frameworks in 2026
Flutter, React Native, Compose Multiplatform and the rest on the same three axes as the table above.
Inside a Duet Android app
The emitted Android tree file by file: feature modules, the thin app module, workers and fixtures.
Duet glossary
Reducer, worker, scenario, fixture, the checks — every term defined with one link each.
The Duet framework on GitHub
The open framework repository — both flavors, the toolchain, and the versioned contracts.
React Native vs native
The same shared-core-versus-shipped-runtime question argued for a product audience, on modaal.dev.