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.

  • Mobile & Desktop
  • $kotlin-multiplatform
  • 195 lines
  • 2 files
  • ~2,250 tokens
  • MIT
  • Updated

Install

curl -fsSL codexskills.dev/install.sh \  | sh -s -- kotlin-multiplatform

Installs to ~/.agents/skills/kotlin-multiplatform/ for every project on this machine.

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.

~/.agents/skills/kotlin-multiplatform/SKILL.md

--- name: kotlin-multiplatform description: Builds and maintains Kotlin Multiplatform (KMP) projects that share business logic and optionally UI across Android, iOS, desktop and web - source… license: MIT ---

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

  1. Read AGENTS.md for the Gradle command, JDK version and module naming rules.
  2. Open gradle/libs.versions.toml, the root settings.gradle.kts and the shared module’s build.gradle.kts. Note the Kotlin, AGP, Compose Multiplatform and coroutines versions; these must be mutually compatible, so upgrade them together, never one alone.
  3. List targets: ./gradlew :shared:tasks --all | grep -i -E "compile.*Kotlin".
  4. Find how iOS consumes the module: direct framework via embedAndSignAppleFrameworkForXcode (a Run Script phase in Xcode), CocoaPods plugin, or an XCFramework / SPM package.
  5. 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")) }
    }
}
CodeWhere it goes
Models, validation, use cases, repositories, state machinescommonMain
HTTP, JSON, persistence through KMP librariescommonMain with per-platform engine/driver
Keychain, biometrics, file pickers, platform SDKsandroidMain / iosMain behind an interface
UINative per platform, or Compose Multiplatform in commonMain (see below)
Anything touching Context, UIViewController, NSBundlePlatform 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

NeedDefaultNotes
HTTPKtor clientOne engine per platform: OkHttp, Darwin, Java/CIO
JSONkotlinx.serializationApply the serialization Gradle plugin
DatabaseSQLDelight, or Room (KMP-capable since 2.7)Room if the Android team already uses it
Key-valueDataStore (KMP) or multiplatform-settings
DIKoinHilt is Android-only
Date/timekotlinx-datetimeNever java.time in commonMain
Swift interopSKIE or KMP-NativeCoroutinesMakes 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 fun becomes a completion-handler / async function 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 (exposes AsyncSequence) 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; Int maps to Int32, Long to Int64.
  • 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 commonMain under composeApp; iOS hosts it with ComposeUIViewController { App() } returned from a Kotlin function and wrapped in a SwiftUI UIViewControllerRepresentable.
  • Resources go in src/commonMain/composeResources/ and are accessed through the generated Res class (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.time to kotlinx-datetime, Gson/Moshi to kotlinx.serialization, Retrofit to Ktor, java.io.File to okio or platform interface
  • Move pure code to commonMain; leave Android-only pieces in androidMain
  • Introduce interfaces for platform services; wire them in DI per platform
  • Port the slice’s unit tests to commonTest and run them on JVM and iOS simulator
  • Call the slice from Swift; add @Throws and 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 iosSimulatorArm64 on Apple Silicon simulators.
  • isStatic = true frameworks 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.cacheKind defaults and enable the Gradle build cache in CI.
  • Mutable global state in object declarations 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.Main on iOS requires the main run loop; command-line iOS tests that touch it need kotlinx-coroutines-test and Dispatchers.setMain.

Verification

  1. ./gradlew :shared:allTests passes on macOS (all targets) and on the CI matrix.
  2. The Android app and the Xcode project both build from a clean checkout.
  3. A Swift call into each new public API compiles without force-casts and handles errors with do/try/catch.
  4. If behaviour differs between platforms, write a commonTest that 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.yamlgenerated from this page

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.

esc
Just launched

Codex Skills Pro is live

79 premium skills for Codex, each with scripts, references and an evidence report that proves the result.

Bought one by one$882.21
All 79 skills$49
94% off

That's $0.62 a skill, paid once.

Get all 79 for $49Or pick single skills from $4.99 →

Secure checkout by Creem