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

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

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.

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 card in the Multiplatform lane

The wizard’s first step groups templates into lanes. The Multiplatform (iOS + Android) lane at the top holds the two Duet cards:
Modaal new-project wizard: the Multiplatform lane with the 'iPhone now, Android later' and 'iPhone and Android together' Duet template cards

The Multiplatform lane — the two Duet cards. 'iPhone and Android together' is preselected.

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.

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 twin of any Kotlin rule.
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.

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

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

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

What Duet asks 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. 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.

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

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.

The Duet framework on GitHub

The open framework repository — both flavors, the toolchain, and the versioned contracts.