Duet is the architecture behind Modaal’s iOS + Android projects: feature logic written once, native SwiftUI and Jetpack Compose interfaces, and a CI gate that fails when the two apps disagree.
Duet is the architecture Modaal scaffolds when you pick one of the two Multiplatform cards in the new-project wizard. 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 they stop agreeing.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. The Duet cards carry a Beta chip in the wizard. Android is in open beta, with general availability in September 2026 — join the open beta for priority access. Modaal Free builds one platform of your choice; building iOS and Android together is part of Pro.
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 — never 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 is a red build on the commit that caused it.
Android is a target, not a rewrite
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
Approach
Who draws the screen
Where feature logic lives
What proves the two platforms agree
Flutter, React Native
One toolkit renders on both platforms; the widget set belongs to the framework
Once, in Dart or JavaScript
Nothing separate — there is one implementation
Compose Multiplatform
Compose renders on both platforms
Once, in Kotlin
Nothing separate — there is one implementation
Two separate codebases
Each platform’s native toolkit
Twice, once per platform
Manual QA on both apps
Duet
Each platform’s native toolkit — SwiftUI and Compose
Once, in a pure reducer per feature
Recorded fixtures replayed on both platforms; CI compares byte-for-byte
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, not a second engineering plan: the behavior underneath is already written, recorded and passing.
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 groups templates into lanes. The Multiplatform (iOS + Android) lane at the top holds the two Duet cards:
The Multiplatform lane — 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)
Both carry a Beta chip. iPhone and Android together also carries Default — it is the card selected when the wizard opens. The question mark beside the architecture line opens this page.Below the Multiplatform lane sits the iOS lane with the single-platform templates. Production app and 2D game / Interactive app carry a Legacy chip there. Those two are CombineRIBs templates: projects already on them are fully supported and keep getting features, and the chip marks that a Duet card is the one to pick for a new production app. 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.
No Multiplatform lane? Android support is not enabled for your installation, so the wizard offers the iOS lane only and preselects Production app. Join the Android open beta to get the Duet cards.
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 for your organization, e.g. com.acme.
Bundle identifier
Derived as <organization identifier>.<AppName>. You can override the suffix.
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 twin 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 absorb the Kotlin core up front than schedule the move later. What it asks: 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.
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/
The part that matters most is the one you would not choose to build for an iOS-only app: 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. Nothing about the discipline is deferred because Android is.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 asks: 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 in the iOS lane — no Duet card scaffolds a SpriteKit scene host
You are validating a throwaway idea
Quick prototype in the iOS lane — 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 — never as a second, hand-maintained tree that has to be 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 rather than a broken one. You can stop between any two features and keep everything banked so far.
This is the same shape as the CombineRIBs → Duet migration an existing Production app project takes — with the expensive parts already paid:
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 the graft and the per-feature translation: the Kotlin reducer has an executable specification to satisfy before its first line is written, which is what makes the port bounded work rather than a rewrite.
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 they win any disagreement with the prose.
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.
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. There is no verb 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 neither knows the other exists. 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.
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 happens to keep a portable record of its own behavior.
What happens when one platform can't do what the other does?
It doesn’t, and the project’s parity/manifest.yaml records why. Apple sign-in has no Android counterpart, so the Android environment maps that path to a failure the shared reducer already models and the button does not exist. No contract change is needed, and the shared half never assumed the button.
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.