Kotlin Multiplatform skill for Codex
Share the logic, keep the platforms honest. Codex moves code into commonMain one vertical slice at a time, puts platform services behind injected interfaces instead of sprawling expect/actual classes, and checks every library publishes artifacts for every target. It annotates Swift-facing suspend functions with @Throws, reaches for SKIE where Flow and sealed types need it, and verifies on both the Android and iOS simulator test tasks.
curl -fsSL codexskills.dev/install.sh \ | sh -s -- kotlin-multiplatform Installs to ~/.agents/skills/kotlin-multiplatform/ for every project on this machine.
curl -fsSL codexskills.dev/cli.mjs -o codex-skills.mjsnode codex-skills.mjs install kotlin-multiplatform The CLI also searches, updates and removes skills. Run it with no arguments to pick from a list. Needs Node 20.12+.
curl -fsSL codexskills.dev/install.sh \ | sh -s -- --project kotlin-multiplatform Installs to .agents/skills/kotlin-multiplatform/ in the current repository. Commit it and everyone who runs Codex there gets it.
curl -fsSLO codexskills.dev/downloads/kotlin-multiplatform.zipunzip -o kotlin-multiplatform.zip -d ~/.agents/skills/ The archive holds the skill folder with its generated agents/openai.yaml. Unzip into .agents/skills/ to scope it to a repo.
& ([scriptblock]::Create((irm codexskills.dev/install.ps1))) kotlin-multiplatform PowerShell 5.1 or 7. Installs to %USERPROFILE%\.agents\skills\. Add -Project for the current repo.
104-line POSIX script. No sudo, no telemetry. Read install.sh
Use it in Codex
Start a prompt with $kotlin-multiplatform, or pick it from /skills.
You can also just describe the task: Codex reads the description below and loads the playbook when your request matches.
What Codex reads before loading it
Builds and maintains Kotlin Multiplatform (KMP) projects that share business logic and optionally UI across Android, iOS, desktop and web - source set layout, expect/actual versus interfaces, Compose Multiplatform screens, Ktor, SQLDelight or Room, coroutines and Flow exposed to Swift, and the Xcode framework integration. Use when adding a shared module, moving Android code into commonMain, fixing iOS link or Swift interop errors, choosing KMP libraries, or setting up Compose Multiplatform.
Use it when
- Starting or extending a shared Kotlin module for Android and iOS
- Porting JVM-only Android code into commonMain
- Swift callers struggle with Flow, sealed classes or exceptions from Kotlin
- Evaluating or setting up Compose Multiplatform
Use something else when
- The app is Android-only and the question is ViewModel, Hilt or Room structure: use android-architecture
- General Kotlin coroutine or Flow design with no multiplatform angle: use kotlin-coroutines
How to check the result
Run ./gradlew :shared:allTests on macOS so JVM, Android and iosSimulatorArm64 tests all execute, then build both host apps from a clean checkout.
The mistake it prevents
Calling a shared suspend function from Swift without @Throws, so any Kotlin exception becomes an app crash instead of a Swift error.
Done well means: commonMain holds the logic that is truly the same everywhere, platform
code sits behind small interfaces, every target compiles and runs its tests in CI, and Swift
callers get APIs that feel native instead of leaked Kotlin types.
Before changing anything
- Read
AGENTS.mdfor the Gradle command, JDK version and module naming rules. - Open
gradle/libs.versions.toml, the rootsettings.gradle.ktsand the shared module’sbuild.gradle.kts. Note the Kotlin, AGP, Compose Multiplatform and coroutines versions; these must be mutually compatible, so upgrade them together, never one alone. - List targets:
./gradlew :shared:tasks --all | grep -i -E "compile.*Kotlin". - Find how iOS consumes the module: direct framework via
embedAndSignAppleFrameworkForXcode(a Run Script phase in Xcode), CocoaPods plugin, or an XCFramework / SPM package. - First Gradle sync downloads dependencies and Kotlin/Native toolchains; in a Codex sandbox with network off, ask for approval or work only on already-cached builds.
Source set layout
The default hierarchy template creates intermediate source sets automatically; do not
hand-wire dependsOn unless you need a custom grouping.
kotlin {
androidTarget()
jvm("desktop")
listOf(iosX64(), iosArm64(), iosSimulatorArm64()).forEach {
it.binaries.framework {
baseName = "Shared"
isStatic = true
}
}
sourceSets {
commonMain.dependencies {
implementation(libs.kotlinx.coroutines.core)
implementation(libs.kotlinx.serialization.json)
implementation(libs.ktor.client.core)
}
androidMain.dependencies { implementation(libs.ktor.client.okhttp) }
iosMain.dependencies { implementation(libs.ktor.client.darwin) }
commonTest.dependencies { implementation(kotlin("test")) }
}
}
| Code | Where it goes |
|---|---|
| Models, validation, use cases, repositories, state machines | commonMain |
| HTTP, JSON, persistence through KMP libraries | commonMain with per-platform engine/driver |
| Keychain, biometrics, file pickers, platform SDKs | androidMain / iosMain behind an interface |
| UI | Native per platform, or Compose Multiplatform in commonMain (see below) |
Anything touching Context, UIViewController, NSBundle | Platform source set only |
expect/actual versus interfaces
Default to an interface in commonMain with implementations injected from each platform.
It is testable with fakes and works with any DI container (Koin is the common KMP choice).
// commonMain
interface SecureStore {
suspend fun put(key: String, value: String)
suspend fun get(key: String): String?
}
// androidMain: EncryptedFile or DataStore + Keystore
// iosMain: Keychain via platform.Security
Use expect/actual only for small leaf declarations that have no state and need no
substitution in tests: a platform name, a UUID factory, a Dispatchers choice, a
@Parcelize typealias. expect fun is stable. expect class is still Beta and needs the
-Xexpect-actual-classes compiler flag; prefer an interface instead of opting in.
Library choices
| Need | Default | Notes |
|---|---|---|
| HTTP | Ktor client | One engine per platform: OkHttp, Darwin, Java/CIO |
| JSON | kotlinx.serialization | Apply the serialization Gradle plugin |
| Database | SQLDelight, or Room (KMP-capable since 2.7) | Room if the Android team already uses it |
| Key-value | DataStore (KMP) or multiplatform-settings | |
| DI | Koin | Hilt is Android-only |
| Date/time | kotlinx-datetime | Never java.time in commonMain |
| Swift interop | SKIE or KMP-NativeCoroutines | Makes Flow, suspend and sealed types usable from Swift |
Before adding any library, confirm it publishes artifacts for every declared target
(check its Maven metadata or README target list). A JVM-only dependency in commonMain
fails only when the iOS target compiles.
Making the API pleasant for Swift
Kotlin is exported to Swift through an Objective-C header unless you adopt Swift export (still experimental). That header loses information:
suspend funbecomes a completion-handler /asyncfunction that can throw; uncaught Kotlin exceptions crash the app unless the function is annotated@Throws(Exception::class)(or a narrower type). Annotate every suspend and throwing function called from Swift.Flow<T>arrives as an opaque type. Use SKIE (exposesAsyncSequence) or wrap it.- Sealed classes become class hierarchies without exhaustive
switch; SKIE fixes this. - Default arguments are not exported; Swift sees only the full signature.
- Generics on interfaces are erased;
Intmaps toInt32,LongtoInt64. - Keep the exported surface small: mark helpers
internal, expose a few façade classes.
class FeedRepository(private val api: FeedApi) {
@Throws(Exception::class)
suspend fun refresh(): List<Post> = api.fetchPosts()
}
Compose Multiplatform
Compose Multiplatform is stable for Android, iOS and desktop; web (Wasm) is in Beta. Adopt it when one team owns all platforms and visual parity matters more than platform-native feel.
- UI lives in
commonMainundercomposeApp; iOS hosts it withComposeUIViewController { App() }returned from a Kotlin function and wrapped in a SwiftUIUIViewControllerRepresentable. - Resources go in
src/commonMain/composeResources/and are accessed through the generatedResclass (Res.string.title,Res.drawable.logo). - Keep navigation, view models and state in common code (JetBrains publishes multiplatform lifecycle-viewmodel and navigation-compose artifacts).
- Text input, scrolling physics and accessibility on iOS still differ from UIKit: test with VoiceOver and Dynamic Type on device before promising native parity.
- Mixing is allowed: a native SwiftUI app can embed one Compose screen, and vice versa with
UIKitView.
Workflow: moving Android code into commonMain
- Pick one vertical slice (one repository plus its models), not the whole app
- Replace JVM-only APIs:
java.timeto kotlinx-datetime, Gson/Moshi to kotlinx.serialization, Retrofit to Ktor,java.io.Fileto okio or platform interface - Move pure code to
commonMain; leave Android-only pieces inandroidMain - Introduce interfaces for platform services; wire them in DI per platform
- Port the slice’s unit tests to
commonTestand run them on JVM and iOS simulator - Call the slice from Swift; add
@Throwsand SKIE where the call site is awkward - Ship, then repeat for the next slice
Commands
./gradlew :shared:allTests # every target that can run here
./gradlew :shared:testDebugUnitTest # Android-side unit tests
./gradlew :shared:iosSimulatorArm64Test # needs macOS with Xcode
./gradlew :shared:linkDebugFrameworkIosSimulatorArm64
./gradlew :shared:assembleXCFramework # if the XCFramework DSL is configured
iOS targets build only on macOS. On Linux CI, run JVM and Android tests there and iOS tests on a macOS runner; do not let the Linux job silently skip native targets and call it green.
Gotchas
- Kotlin/Native link failures (“Undefined symbols”) usually mean a static framework with a
missing transitive native dependency, or a mismatched architecture slice; check that the
Xcode build uses
iosSimulatorArm64on Apple Silicon simulators. isStatic = trueframeworks are smaller and launch faster but cannot be shared between an app and its extensions without duplicating code; use a dynamic framework if a widget or share extension also links it.- Gradle configuration cache and Kotlin/Native compile are slow on first run; keep
kotlin.native.cacheKinddefaults and enable the Gradle build cache in CI. - Mutable global state in
objectdeclarations is shared across threads on iOS; guard it the same way you would on the JVM. - Kotlin/Native frameworks are not binary-compatible across Kotlin versions; rebuild the framework whenever Kotlin is upgraded and never commit stale binaries.
Dispatchers.Mainon iOS requires the main run loop; command-line iOS tests that touch it needkotlinx-coroutines-testandDispatchers.setMain.
Verification
./gradlew :shared:allTestspasses on macOS (all targets) and on the CI matrix.- The Android app and the Xcode project both build from a clean checkout.
- A Swift call into each new public API compiles without force-casts and handles errors
with
do/try/catch. - If behaviour differs between platforms, write a
commonTestthat reproduces it before fixing.
Output
When reporting a KMP change, state:
**Shared:** <what moved to commonMain>
**Platform code:** <interfaces added and their Android / iOS implementations>
**Swift surface:** <new public types and how Swift calls them>
**Targets verified:** android unit / iosSimulatorArm64 / desktop - pass or not run (why) What the zip contains
SKILL.md plus the interface file below. Nothing runs on install.
agents/openai.yaml
Gives the Codex app a display name, a one-line description and a starter prompt for this skill.
interface:
display_name: "Kotlin Multiplatform"
short_description: "Share the logic, keep the platforms honest"
default_prompt: "$kotlin-multiplatform Move our Android repository layer into a KMP shared module the iOS app can use" About this skill
Written and maintained by the Codex Skills editors to the same editorial standard as every skill here: a selective description, a real workflow, known pitfalls and a way to verify the work. First published , last updated . MIT licensed. Something wrong or out of date? Tell us.