mcpbeat Sign in

Compose Multiplatform Agent Skill

Use when building one shared Compose UI in Kotlin across Android, iOS, and desktop — commonMain @Composables, expect/actual, source-set placement, native interop, multiplatform ViewModel/navigation/Koin. NOT a single-platform native build (that is kotlin-android / swift-ios), and NOT Dart/Flutter cross-platform UI (that is flutter).

7k tokens
context cost
the whole folder, loaded on every use
6
files
ships runnable scripts
0
copies elsewhere
how many repositories repackaged it
105
stars on the repo
on the repository, not the skill itself

Install

one command, takes just this skill from the repository
npx skills add https://github.com/ericrisco/rsc-harness --skill compose-multiplatform

What comes with it

15 554 bytes besides the instruction
evals/README.md
evals/cases.yaml
references/ios-interop.md
references/project-setup.md
scripts/verify.sh

The instruction itself

10 sections, as written by the author

Compose Multiplatform

You write one Compose UI tree in commonMain and let each platform be a thin host. The whole discipline is one sentence: common by default, platform by exception. Every line you put in commonMain ships to Android, iOS, and desktop unchanged; every line you put in a platform source set is a deliberate exception you should be able to justify.

Versions floor (2026)

Pin these or the K2 compiler bites you. Verify against current docs before locking a project — these are the floors, not opinions.

  • Compose Multiplatform 1.11.0, bundling Jetpack Compose 1.11.1.
  • Kotlin 2.1.0+ required (2.2.20 recommended for evolving iOS/Web targets). Since CMP 1.8.0 the K2 compiler is mandatory, so *every* dependency must compile against Kotlin 2.1.0+.
  • iOS is Stable (production-ready since CMP 1.8.0, May 2025): feature parity for popular cases, type-safe navigation with deep linking, accessibility (VoiceOver, Full Keyboard Access).
  • Web is Beta (CMP 1.9.0, Sept 2025), runs on WasmGC browsers. Do not promise Web parity — ship Android/iOS/desktop, pilot Web.
  • Platform minimums: Android API 21, iOS 14+, macOS 13 arm64, Windows 10+, Ubuntu 20.04+, desktop JDK 11+ (17+ for jpackage packaging).

Where does this code go?

This is the question you answer dozens of times a day. Default to the leftmost column that compiles.

| Source set | Put here | Concrete example | Never here |

|---|---|---|---|

| commonMain | Shared @Composables, ViewModels, business logic, common interfaces, expect declarations | @Composable fun GreetingScreen(), expect fun platformName(): String | android.*, platform.UIKit, java.awt, androidx.activity |

| androidMain | Activity, actual using Android Context/Build | class MainActivity : ComponentActivity | iOS/desktop-only APIs |

| iosMain | ComposeUIViewController factory, actual via cinterop/platform.* | fun MainViewController() = ComposeUIViewController { App() } | android.* |

| desktopMain | application {} window, Swing interop | application { Window(::exitApplication) { App() } } | mobile-only APIs |

| wasmJsMain (Beta) | Web entry point | ComposeViewport(document.body!!) { App() } | anything you can't ship as Beta |

Why this matters: a platform import in commonMain breaks the build for *every other* target, and the error surfaces in the iOS link step, far from the offending line. Keep commonMain import-clean.

Project structure (the 2026 default)

The current default KMP layout (announced May 2026, aligned with AGP 9.0) is a dedicated shared KMP library module + per-platform app modules, not the old single composeApp:

my-app/
  shared/            # KMP library: commonMain holds the Compose UI tree
    src/
      commonMain/    # @Composables, ViewModels, expect declarations, DI
      androidMain/   # actual impls using android.*
      iosMain/       # actual impls + ComposeUIViewController
      desktopMain/   # actual impls + application {} window
      wasmJsMain/    # web entry (Beta)
  androidApp/        # thin Android host -> setContent { App() }
  iosApp/            # Xcode project -> embeds the shared framework
  desktopApp/        # ./gradlew :desktopApp:run
  webApp/            # WasmGC entry (Beta)

Split rule: if some screens are native and only *some* are shared Compose, split into sharedLogic (all platforms) + sharedUI (CMP platforms only). A server-inclusive project adds a root core module. Don't pre-split — start with one shared module and split when a platform genuinely needs native UI.

Source-set hierarchy — commonMain fans out, with intermediate sets where targets share code:

commonMain
├── androidMain
├── desktopMain (jvm)
├── wasmJsMain (Beta)
└── iosMain (intermediate)
    ├── iosArm64
    └── iosSimulatorArm64

Scaffold a new project with kmp.new or the Kotlin Multiplatform wizard (IntelliJ IDEA 2025.2.2+ / Android Studio Otter 2025.2.1+ with the KMP plugin). Add a shared module to an *existing* Android app via Android Studio's Shared Module Template.

Minimal version-catalog plugin wiring (full Gradle in references/project-setup.md):

// gradle/libs.versions.toml
[versions]
kotlin = "2.2.20"
compose = "1.11.0"
agp = "9.0.0"

[plugins]
kotlinMultiplatform = { id = "org.jetbrains.kotlin.multiplatform", version.ref = "kotlin" }
composeMultiplatform = { id = "org.jetbrains.compose", version.ref = "compose" }

expect / actual — the core mechanism

expect/actual is how you reach a platform API while keeping the call site common. Declare expect in commonMain; provide an actual in every target you compile.

// commonMain
expect fun platformName(): String
// androidMain
import android.os.Build
actual fun platformName(): String = "Android ${Build.VERSION.SDK_INT}"
// iosMain
import platform.UIKit.UIDevice
actual fun platformName(): String =
    UIDevice.currentDevice.systemName + " " + UIDevice.currentDevice.systemVersion

Rules, each with the reason it exists:

  • Every expect needs an actual in every compiled target. An orphan expect is not a warning — it is a hard build failure (often only surfacing on the iOS target), so add the actual per target or remove the target.
  • Keep the common surface tiny. Each expect symbol multiplies into N actuals you maintain; expose the smallest function, not a fat class.
  • Prefer a common interface + DI over deep expect trees for anything you want to test or fake. expect class can't be mocked in common tests.
// Bad: deep expect class — N actuals, untestable in commonTest
expect class Database {
    fun query(sql: String): List<Row>
    fun close()
}
// Good: common interface, platform impls injected via Koin (fakeable in tests)
interface Database {
    fun query(sql: String): List<Row>
    fun close()
}
// androidMain/iosMain provide SqliteDatabase implementing Database, bound in a Koin module.

Native interop

You bridge in both directions. Shared Compose embeds native views; native hosts embed shared Compose.

  • iOS — native view inside shared Compose: UIKitView / UIKitViewController with a factory lambda.
  • iOS — shared Compose inside SwiftUI: wrap ComposeUIViewController in a UIViewControllerRepresentable.
  • Android: AndroidView for native views; host the tree via setContent { App() } in an Activity.
  • Desktop: application { Window { App() } }; Swing interop via SwingPanel.

Embed a native view through an *injected interface*, not a raw expect — so the common screen stays platform-agnostic and testable:

// commonMain
interface MapFactory { /* returns a platform map handle */ }

@Composable
fun MapScreen(mapFactory: MapFactory = koinInject()) {
    // iosMain provides the actual UIKitView wiring around mapFactory; see references/ios-interop.md
}

Full bridge patterns (ComposeUIViewController SwiftUI wrapper, native-view-factory-via-Koin, MapKit/camera, ViewModel lifecycle) live in references/ios-interop.md — read it before writing iOS interop.

State, ViewModel, navigation, DI

  • androidx.lifecycle.ViewModel works in commonMain. Obtain instances with koin-compose-viewmodel's koinViewModel { } so they survive recomposition. iOS has no built-in ViewModelStoreOwner — tie the VM lifecycle to SwiftUI manually (KMP-ObservableViewModel lets SwiftUI observe Kotlin VMs).
  • Koin is the common DI runtime. Define a shared initKoin() and call it from the Android Application and from iOS app init:
// commonMain
fun initKoin(config: KoinAppDeclaration? = null) = startKoin {
    config?.invoke(this)
    modules(appModule, platformModule)
}
  • Navigation: androidx.navigation provides type-safe nav + deep links in commonMain.
  • Resources: compose.components.resources generates Res accessors — Res.string.app_name, Res.drawable.logo, fonts — shared across all platforms.

Running & packaging

  • Android: run the androidApp run config (hosts via setContent).
  • iOS: open iosApp in Xcode, or use the KMP iOS run config in the IDE.
  • Desktop: ./gradlew :desktopApp:run; package with ./gradlew :desktopApp:packageDistributionForCurrentOS (needs JDK 17+ for jpackage).
  • Web (Beta): ./gradlew :webApp:wasmJsBrowserDevelopmentRun.

Anti-patterns

| Anti-pattern | Why it bites | Do instead |

|---|---|---|

| android.* / platform.UIKit / java.awt import in commonMain | Breaks the build for every other target, error surfaces far away | expect/actual or inject via a common interface |

| expect with no actual for a target | Hard build failure on that target | Add an actual per compiled target or drop the target |

| Recreating a ViewModel each recomposition (remember { VM() } wrong) | State loss on every recompose | koinViewModel { } / hoist state |

| Treating Compose Web as production | Web is Beta (1.9), not Stable | Ship Android/iOS/desktop; pilot Web only |

| Kotlin < 2.1.0 with CMP 1.8+ | K2 incompatibility — deps fail to link | Bump to Kotlin 2.2.x |

| Deep expect class for testable logic | Can't fake in commonTest | Common interface + Koin-injected platform impl |

| Pre-splitting into sharedLogic/sharedUI on day one | Premature complexity, extra Gradle wiring | Start with one shared module; split when a platform needs native UI |

Verify

After scaffolding or editing, run scripts/verify.sh <project-dir> (read-only, no Gradle/Xcode needed). It statically checks the structural invariants:

  • a commonMain source set exists;
  • every expect in commonMain has a matching actual in some platform source set (catches orphans);
  • the Compose Multiplatform plugin (org.jetbrains.compose) and a Kotlin version are present, and Kotlin is >= 2.1.0 (K2 floor);
  • no forbidden platform imports leak into commonMain.

It exits 0 on a clean or empty target and non-zero only on hard failures.

How to use it

Copy the folder

Take ericrisco/compose-multiplatform from the repository into ~/.claude/skills for personal use, or into .claude/skills inside a project.

Check the name does not clash

The agent identifies a skill by the name field in its header. Two skills with the same name cannot sit side by side — one of them will be ignored.