Skip to main content
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.

What you get

Feature logic written once

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

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.

Starting a Duet project

1. Describe what you’re building

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.

2. Pick a Duet card

The wizard’s first step shows the template cards two to a row. The first row holds the two Duet cards:
Modaal new-project wizard: the 'iPhone now, Android later' and 'iPhone and Android together' Duet template cards side by side, the second one selected and carrying the Default chip

The first row of the wizard — the two Duet cards. 'iPhone and Android together' is preselected.

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.

3. Set the name, identifiers and targets

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

4. Create

Pressing Create runs three steps:
1

The project is created with its final identity

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.

5. The first turn: PRD and first feature spec

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.

The two Duet templates

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

iPhone now, Android later: the Swift flavor

Template id: duet-swift-ios · Emits: one iPhone app · Feature logic: Swift packages Feature 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.
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.

Choosing between them

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.

Adding Android to an iPhone-only project

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

How a feature gets built

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.

What Duet requires of you

Stated plainly, so nothing is a surprise later:
  • 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.

Common questions

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.
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.
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)
SceneDelegate.swift (simplified)
SplashFeature.kt (commonMain, simplified)
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.
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)
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)
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.
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.
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.
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.
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.
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.

Duet tutorials

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.
Last modified on September 16, 2026