Kotlin Multiplatform skill for Claude Code
Share the logic, keep the native feel on Android and iOS. Claude decides what belongs in commonMain and what stays native, builds the shared module with the AGP 9 Android-KMP plugin, Ktor and SQLDelight or Room KMP, and designs a Swift-facing facade with @Throws and SKIE so iOS code gets async functions and exhaustive enums. It treats Swift export as Alpha and plans adoption one slice at a time.
- name
kotlin-multiplatform - size203 lines · ≈2,750 tokens
- files2
- licenseMIT
- updated
What Claude sees before loading it
This description is the only part of the skill that sits in context all the time. Claude reads it to decide when to load the full playbook below.
description: Builds and introduces Kotlin Multiplatform (KMP) shared code for Android and iOS - deciding what to share (domain, data, networking with Ktor, persistence with SQLDelight or Room KMP) versus what stays native, expect/actual versus interfaces, Compose Multiplatform as an optional UI layer, Swift-friendly APIs with SKIE or the Alpha Swift export, Gradle module layout under AGP 9, direct, SwiftPM or CocoaPods integration, commonTest, and an incremental adoption path in existing apps. Use when adding a shared module to Android and iOS apps, writing commonMain code, exposing Kotlin flows or suspend functions to Swift, migrating off CocoaPods, or evaluating KMP versus Flutter or React Native. Try asking
- Move our pricing and cart logic into a KMP module both apps can use
- Expose this repository's Flow to SwiftUI without callback wrappers
- Migrate our shared module to AGP 9 and drop CocoaPods for SwiftPM
Use it when
- Adding a shared Kotlin module to existing Android and iOS apps
- Writing commonMain code with Ktor, SQLDelight or Room KMP
- Designing the Swift-facing API of a shared framework
- Upgrading a KMP project to AGP 9 or moving off CocoaPods
Skip it when
- The work is Android-only module structure and Hilt — use android-architecture
- You are writing idiomatic Kotlin without multiplatform concerns — use kotlin-idioms
How to check the result
allTests passes on Linux and on a macOS iOS simulator runner, both apps build from a fresh clone, and a Swift test receives a thrown Kotlin error as a Swift Error.
The common mistake it prevents
Leaving @Throws off a shared function that Swift calls, so the first network failure terminates the iOS app instead of showing an error.
Done well means: one shared module owns business rules, data access and networking;
each platform keeps a native-feeling UI unless Compose Multiplatform was chosen on
purpose; Swift code calls the shared API without casts, completion-handler pyramids or
crashes from unchecked Kotlin exceptions; commonTest runs on both JVM and the iOS
simulator in CI; and the iOS team can build without learning Gradle internals.
Current baseline (verify before pinning)
| Piece | State in late 2026 |
|---|---|
| Kotlin | 2.4.x. Default Apple minimums rose to iOS 15 / macOS 12 in 2.4; concurrent mark-and-sweep GC is the Kotlin/Native default |
| Android plugin | AGP 9 drops KMP support in com.android.library/com.android.application; shared modules use com.android.kotlin.multiplatform.library with a kotlin { android { } } block (androidLibrary {} was the pre-8.12 name, now deprecated). Gradle 9.1+ |
| Compose Multiplatform | 1.11 current; iOS target is stable |
| Swift export | Alpha in Kotlin 2.4: suspend to async, Flow to AsyncSequence, but generics are erased and it requires direct integration. Not for production yet |
| SKIE (Touchlab) | Stable plugin over the Objective-C export; supports Kotlin 2.0.0 through 2.4.10 at time of writing |
| SwiftPM | Kotlin 2.4 adds swiftPMDependencies {} to consume Swift packages from Kotlin; the CocoaPods plugin is the legacy path with an official migration guide |
| Room | KMP-capable since 2.7 (stable); a separate androidx.room3 line is in alpha |
| SQLDelight | 2.4.0 (app.cash.sqldelight) |
Check versions in the repo first: grep -nE "kotlin|agp|compose|ktor|sqldelight|room|skie" gradle/libs.versions.toml.
What to share
| Layer | Share? | Notes |
|---|---|---|
| Domain models, validation, business rules, formatting of numbers/dates | Yes | Highest value, lowest risk; start here |
| Networking (Ktor client, DTOs, auth refresh, retry) | Yes | ktor-client-okhttp on Android, ktor-client-darwin on iOS |
| Persistence and caching | Yes | SQLDelight (SQL-first, mature on iOS) or Room KMP (if the Android team already uses Room) |
| Repositories, use cases, sync logic | Yes | Expose suspend + Flow/StateFlow |
| ViewModels / presenters | Often | androidx.lifecycle ViewModel is multiplatform; on iOS wrap in an @Observable adapter |
| UI | Decide explicitly | Native SwiftUI + Compose by default for brownfield apps; Compose Multiplatform when one team owns both and pixel-identical UI is acceptable |
| Platform services (push, biometrics, payments, camera, widgets, App Intents) | No | Keep native; call through interfaces injected into shared code |
| Analytics and crash SDKs | Interface in common, SDK native | Avoid binding vendor SDKs into the shared framework |
Workflow
- [ ] 1. Inventory: Kotlin/AGP/Gradle versions, iOS integration style, existing Android modules
- [ ] 2. Pick the first shared slice (models + one repository) and the persistence library
- [ ] 3. Create the shared module with the AGP 9 Android-KMP plugin and iOS targets
- [ ] 4. Write commonMain code with interfaces for platform services
- [ ] 5. Design the Swift-facing API (facade, @Throws, SKIE or plain ObjC export)
- [ ] 6. Wire iOS integration (direct, SwiftPM XCFramework, or CocoaPods)
- [ ] 7. commonTest + platform tests in CI on Linux and macOS runners
- [ ] 8. Ship the slice in both apps behind a flag; then widen
1. Inventory
./gradlew --version
grep -rn "kotlin(\"multiplatform\")\|kotlinMultiplatform\|com.android.kotlin.multiplatform.library" --include=*.kts .
grep -rn "embedAndSignAppleFrameworkForXcode\|embedSwiftExportForXcode" ios*/ *.xcodeproj 2>/dev/null
ls ios*/Podfile Package.swift 2>/dev/null
3. Module layout
shared/
src/commonMain/kotlin/ # domain, data, network, repositories
src/commonTest/kotlin/
src/androidMain/kotlin/ # actuals: Android driver, Context-backed services
src/iosMain/kotlin/ # actuals: Darwin engine, NativeSqliteDriver, Swift-facing facade
src/iosTest/kotlin/
androidApp/ # com.android.application, depends on :shared
iosApp/ # Xcode project
Use the default hierarchy template (commonMain -> appleMain -> iosMain); do not
hand-wire dependsOn for iOS source sets. Targets: iosArm64() and
iosSimulatorArm64(); add iosX64() only if someone still builds on an Intel Mac.
Full build.gradle.kts, version catalog and XCFramework/SwiftPM setup are in
references/gradle-setup.md; read it when creating or
migrating the shared module.
4. expect/actual vs interfaces
| Need | Use |
|---|---|
| Platform service with state or several methods (secure storage, location, analytics) | Interface in common, implementation per platform, injected (Koin or constructor) |
| Tiny platform function (UUID, current locale, platform name) | expect fun / actual fun |
Factory needing a platform handle (SQL driver needs Context) | expect class DriverFactory or a plain interface |
| Entire class differing per platform | Avoid; expect class still needs -Xexpect-actual-classes |
Interfaces let tests use fakes from commonTest without platform code.
5. Swift-facing API
Hard rules for anything Swift calls:
- Annotate every function that can throw with
@Throws(SomeException::class)(or a common base). An undeclared Kotlin exception crossing into Swift terminates the app. - Expose a small facade from
iosMain(SharedSdk,OrderService) instead of every internal class; the ObjC header of a large module is slow to index and confusing. - No generic interfaces on the boundary; ObjC export keeps generics only on classes.
- Sealed hierarchies: with SKIE they become exhaustive Swift enums (
onEnum(of:)); without it, provide awhen-style mapping function or a Swift-friendly enum. - Default arguments do not exist in the ObjC export; add overloads or use SKIE.
- Flows: with SKIE,
Flow/StateFlowbecomeAsyncSequenceusable infor await; without SKIE, expose awatch(callback): Closeablewrapper that the Swift side cancels.
Decision:
| Situation | Interop choice |
|---|---|
| Production app today | ObjC export + SKIE |
| Small surface, team avoids extra plugins | ObjC export + hand-written wrappers |
| Greenfield experiment, direct integration, willing to track breaking changes | Try Swift export (Alpha) on a branch; keep the ObjC path shippable |
6. iOS integration
| Repo shape | Default |
|---|---|
| Monorepo, iOS devs have JDK and can run Gradle | Direct integration: Xcode Run Script phase ./gradlew :shared:embedAndSignAppleFrameworkForXcode |
| Separate iOS repo or iOS devs must not run Gradle | Build an XCFramework in CI (assembleSharedReleaseXCFramework), publish a SwiftPM binaryTarget with checksum |
| Existing CocoaPods setup | Keep working, plan migration to SwiftPM using the official CocoaPods-to-SwiftPM guide; do not start new projects on CocoaPods |
For direct integration set “User Script Sandboxing” to No for the target and keep the Run Script phase before “Compile Sources”.
7. Tests
./gradlew :shared:allTests # every target's tests, aggregated report
./gradlew :shared:tasks --group verification # list per-target test tasks (JVM/Android host tests are the fast loop)
./gradlew :shared:iosSimulatorArm64Test # macOS only; catches Native-only bugs
kotlin.test+kotlinx-coroutines-test(runTest) + Turbine for flows incommonTest.- Ktor:
MockEnginein common tests. SQLDelight: an in-memory driver per platform via a testDriverFactory. Room KMP: in-memory builder withBundledSQLiteDriver. - Android host tests on the Android-KMP plugin are off by default; enable them with
withHostTest {}in theandroid {}block. - CI: Linux runner for JVM/Android tests, macOS runner for iOS tests and XCFramework.
8. Adoption in an existing app
- Pick a slice with clear inputs and outputs and duplicate logic today (pricing rules, form validation, API client for one feature).
- Port the Android implementation into
commonMain(Android code is usually closest), keep the old iOS implementation, and run both behind a flag. - Compare outputs in production or in a shadow test; then delete the iOS duplicate.
- Widen to the repository and persistence layer. UI last, and only if the team wants Compose Multiplatform.
- Measure: iOS app size delta, cold-start delta, build time delta. Report them.
Gotchas
- Never call
runBlockingfrom iOS-facing code; it blocks the Swift caller’s thread. Swift export runs calls onDispatchers.Defaultunless Kotlin switches withwithContext; move heavy work off the main thread inside Kotlin, not in Swift. - On Native,
Dispatchers.IOneedsimport kotlinx.coroutines.IO; without it the code compiles on Android only and the iOS build breaks late. - Kotlin/Native release links are slow and memory-hungry; build debug simulator
frameworks locally (
linkDebugFrameworkIosSimulatorArm64) and release in CI. - Exporting dependency types to Swift requires
export(...)in the framework config (andapidependencies); otherwise Swift sees them under mangled prefixes or not at all. - Two Kotlin frameworks in one iOS app each embed their own runtime and cannot share types. Ship one umbrella framework.
- Room KMP needs KSP added per target (
kspAndroid,kspIosArm64,kspIosSimulatorArm64); missing one fails only that target’s build. java.*APIs are unavailable incommonMain. Usekotlinx-datetime,kotlinx-io,kotlinx-serialization, andkotlin.uuid.Uuid.- Do not change the iOS deployment target silently: Kotlin 2.4 raised the default minimum to iOS 15. Lowering it needs explicit compiler overrides.
Verification
./gradlew :shared:allTestspasses on Linux (JVM/Android) and macOS (iOS simulator).- The Android app and the Xcode project both build clean from a fresh clone using the documented steps.
- A Swift unit test calls one facade method that throws and one that returns a flow;
the error arrives as a Swift
Errorand the flow iterates and cancels. - App size and cold-start deltas on iOS are measured and reported.
If an iOS-only test fails, reproduce with iosSimulatorArm64Test before touching
common code; Native differences (no reflection, different number formatting, stricter
threading in some libraries) are the usual cause.
Output contract
When proposing KMP adoption, deliver:
## KMP plan: <app>
Shared now: <modules/classes> Native: <what stays native and why>
Persistence: <SQLDelight|Room KMP> because <reason>
Swift interop: <ObjC + SKIE | wrappers | Swift export experiment>
iOS integration: <direct | SwiftPM XCFramework | CocoaPods (migration date)>
Versions: Kotlin <x>, AGP <x>, Gradle <x>, CMP <x or n/a>
Tests: <commands and CI runners>
Risks: <binary size, build time, team skills> with how each is measured Bundled files
Loaded only when a step in the skill needs them. Review them here before installing.
references/gradle-setup.md
# Shared module Gradle setup (Kotlin 2.4, AGP 9)
Read this when creating the shared module, migrating it to AGP 9, or setting up
XCFramework/SwiftPM distribution. Replace every `<check>` version with the current
release from the official changelog before committing; do not guess.
## gradle/libs.versions.toml
```toml
[versions]
kotlin = "2.4.0"
agp = "9.0.0" # needs Gradle 9.1+
coroutines = "<check>"
serialization = "<check>"
ktor = "<check>" # 3.x
sqldelight = "2.4.0"
skie = "<check>" # must list your Kotlin version as supported
turbine = "<check>"
[libraries]
coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "coroutines" }
coroutines-test = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", version.ref = "coroutines" }
serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "serialization" }
ktor-client-core = { module = "io.ktor:ktor-client-core", version.ref = "ktor" }
ktor-client-content-negotiation = { module = "io.ktor:ktor-client-content-negotiation", version.ref = "ktor" }
ktor-serialization-json = { module = "io.ktor:ktor-serialization-kotlinx-json", version.ref = "ktor" }
ktor-client-okhttp = { module = "io.ktor:ktor-client-okhttp", version.ref = "ktor" }
ktor-client-darwin = { module = "io.ktor:ktor-client-darwin", version.ref = "ktor" }
ktor-client-mock = { module = "io.ktor:ktor-client-mock", version.ref = "ktor" }
sqldelight-android = { module = "app.cash.sqldelight:android-driver", version.ref = "sqldelight" }
sqldelight-native = { module = "app.cash.sqldelight:native-driver", version.ref = "sqldelight" }
sqldelight-coroutines = { module = "app.cash.sqldelight:coroutines-extensions", version.ref = "sqldelight" }
turbine = { module = "app.cash.turbine:turbine", version.ref = "turbine" }
[plugins]
kotlinMultiplatform = { id = "org.jetbrains.kotlin.multiplatform", version.ref = "kotlin" }
kotlinSerialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" }
androidApplication = { id = "com.android.application", version.ref = "agp" }
androidMultiplatformLibrary = { id = "com.android.kotlin.multiplatform.library", version.ref = "agp" }
sqldelight = { id = "app.cash.sqldelight", version.ref = "sqldelight" }
skie = { id = "co.touchlab.skie", version.ref = "skie" }
```
Root `build.gradle.kts` declares each plugin with `apply false`.
## shared/build.gradle.kts
```kotlin
import org.jetbrains.kotlin.gradle.plugin.mpp.apple.XCFramework
plugins {
alias(libs.plugins.kotlinMultiplatform)
alias(libs.plugins.androidMultiplatformLibrary)
alias(libs.plugins.kotlinSerialization)
alias(libs.plugins.sqldelight)
alias(libs.plugins.skie) // optional; remove if using plain ObjC export
}
kotlin {
android { // AGP 8.12+; replaces androidLibrary {}
namespace = "com.example.shared"
compileSdk = 36
minSdk = 24
withHostTest { } // host (unit) tests are opt-in on this plugin
}
val xcf = XCFramework("Shared")
listOf(iosArm64(), iosSimulatorArm64()).forEach { target ->
target.binaries.framework {
baseName = "Shared"
isStatic = true
xcf.add(this)
}
}
sourceSets {
commonMain.dependencies {
implementation(libs.coroutines.core)
implementation(libs.serialization.json)
implementation(libs.ktor.client.core)
implementation(libs.ktor.client.content.negotiation)
implementation(libs.ktor.serialization.json)
implementation(libs.sqldelight.coroutines)
}
androidMain.dependencies {
implementation(libs.ktor.client.okhttp)
implementation(libs.sqldelight.android)
}
iosMain.dependencies {
implementation(libs.ktor.client.darwin)
implementation(libs.sqldelight.native)
}
commonTest.dependencies {
implementation(kotlin("test"))
implementation(libs.coroutines.test)
implementation(libs.ktor.client.mock)
implementation(libs.turbine)
}
}
}
sqldelight {
databases {
register("AppDatabase") {
packageName.set("com.example.shared.db")
}
}
}
```
Notes:
- Under AGP 9 the old top-level `android { }` block and build variants do not exist for
this module. `debugImplementation(...)` becomes `androidRuntimeClasspath(...)`.
- `android.enableLegacyVariantApi=true` in `gradle.properties` is a temporary escape
hatch for plugins that still use the old variant API; AGP 10 removes it.
- If the module combines `com.android.application` with KMP, split it: an
`androidApp` application module plus the `shared` library module.
## Driver factory (SQLDelight)
```kotlin
// commonMain
expect class DriverFactory { fun createDriver(): SqlDriver }
fun createDatabase(factory: DriverFactory): AppDatabase = AppDatabase(factory.createDriver())
// androidMain
actual class DriverFactory(private val context: Context) {
actual fun createDriver(): SqlDriver = AndroidSqliteDriver(AppDatabase.Schema, context, "app.db")
}
// iosMain
actual class DriverFactory {
actual fun createDriver(): SqlDriver = NativeSqliteDriver(AppDatabase.Schema, "app.db")
}
```
## Room KMP instead of SQLDelight
- Add KSP and the Room plugin, `room-runtime` and `androidx.sqlite:sqlite-bundled` in
`commonMain`, and the Room compiler per target:
`add("kspAndroid", ...)`, `add("kspIosArm64", ...)`, `add("kspIosSimulatorArm64", ...)`.
- Declare `@ConstructedBy(AppDatabaseConstructor::class)` on the database and an
`expect object AppDatabaseConstructor : RoomDatabaseConstructor<AppDatabase>`; Room
generates the actuals.
- Build with `.setDriver(BundledSQLiteDriver())` so both platforms run the same SQLite.
- Check which artifact line the project uses (`androidx.room` stable vs `androidx.room3`
alpha) before copying snippets.
## Swift-facing errors
```kotlin
class ApiException(message: String, cause: Throwable? = null) : Exception(message, cause)
class OrderService internal constructor(private val repo: OrderRepository) {
@Throws(ApiException::class, kotlin.coroutines.cancellation.CancellationException::class)
suspend fun placeOrder(cartId: String): OrderSummary = repo.place(cartId)
}
```
Include `CancellationException` in `@Throws` for suspend functions so Swift task
cancellation surfaces as an error instead of a crash.
## Direct integration (monorepo)
Xcode target -> Build Phases -> New Run Script Phase, placed before Compile Sources:
```sh
cd "$SRCROOT/.."
./gradlew :shared:embedAndSignAppleFrameworkForXcode
```
Build Settings: `ENABLE_USER_SCRIPT_SANDBOXING = NO`; add
`$(SRCROOT)/../shared/build/xcode-frameworks/$(CONFIGURATION)/$(SDK_NAME)` to Framework
Search Paths and `-framework Shared` to Other Linker Flags if the wizard did not.
For the Swift export experiment, replace the task with
`./gradlew :shared:embedSwiftExportForXcode` and add a `swiftExport { moduleName = "Shared" }`
block; keep the ObjC path on the main branch.
## SwiftPM distribution (separate iOS repo)
```bash
./gradlew :shared:assembleSharedReleaseXCFramework
cd shared/build/XCFrameworks/release
zip -r Shared.xcframework.zip Shared.xcframework
swift package compute-checksum Shared.xcframework.zip
```
```swift
// Package.swift in the distribution repo
// swift-tools-version: 5.9
import PackageDescription
let package = Package(
name: "Shared",
platforms: [.iOS(.v15)],
products: [.library(name: "Shared", targets: ["Shared"])],
targets: [
.binaryTarget(
name: "Shared",
url: "https://example.com/releases/1.4.0/Shared.xcframework.zip",
checksum: "<output of compute-checksum>"
)
]
)
```
Tag the distribution repo with the same version as the shared module. Automate in CI on
a macOS runner; never hand-edit checksums.
## Consuming Swift packages from Kotlin (Kotlin 2.4)
```kotlin
kotlin {
swiftPMDependencies {
swiftPackage(
url = url("https://github.com/firebase/firebase-ios-sdk.git"),
version = from("12.11.0"),
products = listOf(product("FirebaseAnalytics")),
)
}
}
```
This replaces `cocoapods { pod(...) }` for calling iOS libraries from `iosMain`. Migrate
incrementally: add SwiftPM dependencies alongside the CocoaPods block, move call sites,
then remove CocoaPods last, following the official CocoaPods-to-SwiftPM migration guide.
About this skill
Written and maintained by the Claude Skills editors to the same editorial standard as every skill in the directory: a selective trigger, a real workflow, known pitfalls and a verification step. First published , last updated . MIT licensed.