Does this apply to your project?
import CombineRIBs in its sources and a RIBs/ directory per feature, and it has no parity/manifest.yaml at the repository root. A Duet tree has that file and no CombineRIBs import.
What you end up with
The finished tree is shape-equivalent to one a fresh iPhone and Android together scaffold emits:src-kmp/ holding the Gradle plane, the core modules, one module per feature and the Android app; src-ios/ shells consuming the assembled core; parity/ holding the manifest, the feature specs and the fixtures; and tools/duet. No import CombineRIBs anywhere, and no migration package left.
Duet describes that destination in full. This page is how an app already in flight gets there.
Three properties that shape everything else
Coexistence is first-class
Each conversion is revertible
Every stop is a resting state
Before you start
Two questions worth answering out loud, because they change how much work follows each converted feature rather than which steps you take:What does 'Android' mean for you?
Is a date attached?
The stages
Step 0 — record the decision, and stop digging
AGENTS.md, CLAUDE.md, CONTRIBUTING.md and any always-applied mirrors of them (.cursor/rules/*.mdc, .github/copilot-instructions.md). The edit records the migration decision and one rule: from today, every new feature is authored in the target shape — a reducer plus a thin shell — never as a new Router.Those files instruct every future session in the repository. One left unedited keeps telling the next session to build a Router, which is why this edit comes before the audit rather than after it.Step 1 — the readiness audit
- The readiness report — a census of the distance to the target shape: routers, listener protocols, legacy workers, timing hits, vendor-SDK quarantine breaches, DI and view-binding idioms.
- The migration ledger (
.modaal/duet-migration-ledger.json) — one row per surface, each with a status:legacy,in-progress,converted,retired,blockedorwont-convert. Re-running the audit merges into the same file, so the progress figure is always current. - Spec drafts (
.modaal/duet-migration/spec-drafts/<feature>.md) — a statically extracted head start on each feature spec.
Step 2 — the graft
src-kmp/ (Gradle wrapper, the shared-service, theming and umbrella modules, the per-feature module plane), the Android app shell, and the parity workspace (parity/manifest.yaml, parity/fixtures/, parity/feature-specs/, the pinned tools/duet wrapper). It also emits one synthetic counter feature with the scenario test its fixtures are recorded from.What it does not touch: your legacy app’s sources and tests. It declares the two migration packages in the iOS app’s xcodegen.yml without linking them, and appends a section to the agent documents and a block to .gitignore.Exit gate: the Android app builds and launches on an emulator with the synthetic feature passing from a recorded fixture, and the legacy iOS app builds and runs unchanged.Step 2b — wire the core into the iOS app
Step 3 — the per-feature plan
parity/manifest.yaml and an order in the ledger. Three rules set that order:- Riskiest logic first — the flows whose untested behavior worries you most: auth, save paths, navigation decisions. Conversion is how a feature gains exhaustive tests, so spend it where tests are most needed. It is also the same cost to convert either way.
- Leaves before the spine — self-contained screens before the navigation tree that hosts them.
- Navigation staged last.
Step 4 — features cross, one at a time
Step 5 — the shell swap and the finish line
What crossing one feature looks like
A feature’s logic is authored twice, in one order, inside one window: Swift first, then Kotlin, and the Swift copy is deleted at the end of the crossing. The two halves have different oracles, which is why they are two changes rather than one.First half: the Swift twin, inside the legacy app
Finish the feature spec
#if DEBUG aliases, scheme shortcuts) does not survive into a reducer, and incidental platform-parser tolerances are converged to one chosen, fixture-pinned rule.Extract the pure feature module
State, Action, the reducer, EffectPayload, and an Environment protocol. The reducer is pure: no platform imports, no vendor SDKs, no Date(), no UUID(). Time and identity arrive as action payloads or seeded state.Port the tests and record the fixtures
it becomes a scenario step sequence. Behavior differences found here are findings about the old code, not test bugs. Then the fixtures are recorded and committed.The mutation drill runs here and is not optional: break one reducer guard on purpose, confirm the fixture replay goes red, revert. A drill that stays green means the harness is not wired to anything.Shrink the interactor to a shell
Implement the environment and its fake
Environment protocol is the feature’s only door to the platform, implemented live by adapters over your existing services and for tests by one hand-rolled fake. A feature whose only environment duty is the delegate sink has no port, no worker and no fake — that is a valid shape, not an omission.Second half: the Kotlin twin, and Android
The feature’s manifest row now declares both aswift: and a kotlin: source. That dual state is the window, and it is where the parity lints run.
Write the Kotlin reducer against the recorded fixtures
Mount the feature in the Android app
Retire the Swift twin
kotlin:-only and the Swift copy is deleted in the same change. The iOS app runs the feature from the assembled core — the build wiring for that landed back at step 2b.What happens to each piece of your code
Every CombineRIBs shape has exactly one fate. The Dependency / Component / Builder triple is the destination, not residue — Duet composition uses the same triple at every level.import CombineRIBs. By the time a feature reaches the swap, every hard part is behind it.
What it costs
Measured on a production app of 15 RIBs and roughly 17k lines of RIB-layer code, converted end to end — every feature, router retirement included — in six calendar days of agent-driven sessions with human review:What you can check yourself
The migration installs its own gates at step 2, and they are ordinary commands in your repository:.modaal/duet-migration-ledger.json carries a status per surface, and re-running the audit refreshes it.
Three safety rails hold throughout:
- The leak detector keeps its authority while a feature still has its Router, and stays green through every conversion.
- Visuals get snapshotted during shell swaps. The fixtures pin behavior; snapshots pin pixels.
- Skipped and flaky legacy tests go in a visible registry with an owner — never deleted quietly, and never made to pass by widening a timing bound.
The finish line
The migration is done when all of these hold:- Zero
import CombineRIBsanywhere in the tree. - The migration package’s dependency edge is deleted from every
Package.swift. - Every feature in
parity/manifest.yamliskotlin:-only — noswift:line outlives its window.tools/duet verifyis green andrecord --checkshows zero churn. - The tree is shape-equivalent to a fresh iPhone and Android together scaffold.
- The legacy interactor specs are deleted with their interactors — their scenarios were ported 1:1 during conversion, so a surviving spec tests a deleted type.
- The repository’s own documents are swept: any README or agent-instructions file still describing the RIBs shape is rewritten in the same change. A document describing a shape the repository no longer has is not history — it is instructions to the next reader.
- The app’s own device pass covers the swapped flows.
Four things that are never the answer
Changing the architecture and the language in one step
Changing the architecture and the language in one step
Hand-adding an Android target, Gradle build or Kotlin tree
Hand-adding an Android target, Gradle build or Kotlin tree
Editing a fixture to make a port pass
Editing a fixture to make a port pass
Keeping the migration package after the last legacy symbol is gone
Keeping the migration package after the last legacy symbol is gone
Common questions
Do I have to stop shipping during the migration?
Do I have to stop shipping during the migration?
When does the Android app first run?
When does the Android app first run?
Can I stop half-way?
Can I stop half-way?
What if a feature can't be converted?
What if a feature can't be converted?
blocked or wont-convert status in the ledger with a note saying why, and the migration continues around it. Common blockers are found by the audit before they cost anything: a vendor SDK imported directly in an interactor, or timing done with raw Timer and DispatchQueue.asyncAfter in feature logic. Both are fixed by adding the seam first.Why is the logic written in Swift first and then Kotlin?
Why is the logic written in Swift first and then Kotlin?
What do I actually type to start?
What do I actually type to start?