> ## Documentation Index
> Fetch the complete documentation index at: https://docs.modaal.dev/llms.txt
> Use this file to discover all available pages before exploring further.

# Duet: One Shared Core, Two Native Apps

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

<Note>
  **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](https://modaal.dev/android-open-beta) for priority access. Modaal Free builds one platform of your choice; building iOS and Android together is part of [Pro](/credits-and-plans).
</Note>

## What you get

<CardGroup cols={2}>
  <Card title="Feature logic written once" icon="cube">
    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.
  </Card>

  <Card title="Fully native interfaces" icon="mobile-screen">
    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.
  </Card>

  <Card title="Parity is a build gate" icon="scale-balanced">
    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.
  </Card>

  <Card title="Android is a target, not a rewrite" icon="arrow-right-arrow-left">
    Start iPhone-only and add Android per feature later. The fixtures recorded from the Swift reducer are what the Kotlin one is verified against.
  </Card>
</CardGroup>

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

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

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

<Frame caption="The Multiplatform lane — the two Duet cards. 'iPhone and Android together' is preselected.">
  <img src="https://mintcdn.com/modaal/AYD1mVTqZy0sXufj/images/duet-wizard-cards.png?fit=max&auto=format&n=AYD1mVTqZy0sXufj&q=85&s=5dbdd5bac4cffbd9fc25263b2dc7f0a8" alt="Modaal new-project wizard: the Multiplatform lane with the 'iPhone now, Android later' and 'iPhone and Android together' Duet template cards" width="1128" height="586" data-path="images/duet-wizard-cards.png" />
</Frame>

| 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](/articles/new-project) shows the whole step in one screenshot and covers every card the wizard offers.

<Note>
  **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](https://modaal.dev/android-open-beta) to get the Duet cards.
</Note>

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

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

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

### 4. Create

Pressing **Create** runs three steps:

<Steps>
  <Step title="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.
  </Step>

  <Step title="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.
  </Step>

  <Step title="Your original prompt is sent as the first message">
    The agent receives it with Plan mode on and continues from where the wizard finished.
  </Step>
</Steps>

### 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](/articles/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.

```text theme={null}
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 edge
src-ios/
├── App/              # the iPhone app target; xcodegen.yml is the source
└── Libraries/        # Kit (links the framework), Main (composition root),
                      # Theming, ThemedControls
parity/
├── 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-edited
tools/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.

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

```text theme={null}
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, ThemedControls
parity/
├── manifest.yaml
├── authoring.md
├── feature-specs/    # the porting source of truth
└── fixtures/         # recorded behavior — build products, never hand-edited
tools/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.

### Choosing between them

| Your situation                                                | Card                                                                                          |
| ------------------------------------------------------------- | --------------------------------------------------------------------------------------------- |
| 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 |

<Warning>
  **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.
</Warning>

***

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

<Steps>
  <Step title="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.
  </Step>

  <Step title="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.
  </Step>

  <Step title="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.
  </Step>
</Steps>

This is the same shape as the [CombineRIBs → Duet migration](/articles/combineribs-to-duet) 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.

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

***

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

<Steps>
  <Step title="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.
  </Step>

  <Step title="Write the scenario">
    `Given` / `When` / `Then` — a branch per ending. This is the executable form of the spec.
  </Step>

  <Step title="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.
  </Step>

  <Step title="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.
  </Step>

  <Step title="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.
  </Step>

  <Step title="Verify">
    `tools/duet verify` — the same gate CI runs. It replays every fixture on every platform and compares byte-for-byte.
  </Step>
</Steps>

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

## 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](/credits-and-plans).
* **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](/guides/setup-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

<AccordionGroup>
  <Accordion title="Is this Kotlin Multiplatform?" icon="k">
    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.
  </Accordion>

  <Accordion title="Is the UI shared?" icon="ban">
    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](/articles/cross-platform-ui-parity) is the full treatment of where that line sits.
  </Accordion>

  <Accordion title="Can I ship an App Store app from the Swift flavor?" icon="apple">
    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.
  </Accordion>

  <Accordion title="What happens when one platform can't do what the other does?" icon="triangle-exclamation">
    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.
  </Accordion>

  <Accordion title="Can I open the project in Xcode and Android Studio?" icon="folder-open">
    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.
  </Accordion>

  <Accordion title="Can I add Android to a project that isn't on a Duet card?" icon="arrow-right-arrow-left">
    A **Production app** (CombineRIBs) project has a documented migration path into the Duet shape, taken feature by feature — [Migrating a CombineRIBs app to Duet](/articles/combineribs-to-duet) is the walkthrough. A **Quick prototype** (MVVM) project has no migration path — Android there means a new project.
  </Accordion>

  <Accordion title="Does the shared core cover networking and storage too?" icon="server">
    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.
  </Accordion>
</AccordionGroup>

## Read next

<CardGroup cols={2}>
  <Card title="Handling platform-specific UI" icon="layer-group" href="/articles/cross-platform-ui-parity">
    The technical deep dive: the five-layer presentation contract, the kind→renderer registry, and how deliberate iOS/Android divergence is recorded.
  </Card>

  <Card title="Starting a new project" icon="block-brick" href="/articles/new-project">
    The full wizard walkthrough and every template Modaal offers, Duet and otherwise.
  </Card>

  <Card title="Migrating a CombineRIBs app" icon="arrow-right-arrow-left" href="/articles/combineribs-to-duet">
    Already on the Production app card? The per-feature route from CombineRIBs to a shared Kotlin core and an Android app.
  </Card>

  <Card title="The Duet framework on GitHub" icon="github" href="https://github.com/modaal-agent/duet">
    The open framework repository — both flavors, the toolchain, and the versioned contracts.
  </Card>
</CardGroup>
