Kotlin rules for AI coding agents
Kotlin code runs in two very different places, and these rules cover both: Android apps with Jetpack Compose, and servers on Ktor or Spring Boot. The agent is told to check which one a module is before applying a section, and to take versions from gradle/libs.versions.toml rather than adding inline dependency strings.
Coroutines get the most attention. GlobalScope is out; work launches in a lifecycle-bound scope such as viewModelScope or a server scope you cancel on shutdown. ViewModels expose StateFlow<UiState>, state changes use update { it.copy(...) } so concurrent writers do not lose updates, and Java platform types are checked at the boundary instead of trusted as non-null. On the server, blocking JDBC or file work moves to Dispatchers.IO.
Folder: rules/kotlin/ · kotlin.mdc, CLAUDE.md, AGENTS.md
What the Kotlin rules tell your agent
- LanguagePrefer
val, immutable collections (List,Map) and data classes for values. ExposeMutableListorvaronly when mutation is the point. - CoroutinesNever use
GlobalScope. Launch in a lifecycle-bound scope:viewModelScope,lifecycleScope, or a server request / application scope you cancel on shutdown. - Android and ComposeViewModels expose
StateFlow<UiState>and take events as functions. Build state withstateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), UiState.Loading). - ServerKtor handlers and Spring controllers can be
suspend; keep them non-blocking and move JDBC or file I/O toDispatchers.IO(or use R2DBC/an async driver).
First rule of each section in kotlin.mdc, quoted as shipped.
Mistakes these rules are written to prevent
- Platform types from Java APIs (
String!) treated as non-null. Annotate or check at the boundary. MutableStateFlow.value = value.copy(...)from several coroutines; useupdate { it.copy(...) }for atomic changes.- A
whenon a sealed type withelse ->, which hides new cases.
Source: “Pitfalls to avoid”.
Checks the agent runs before it says done
Example commands from the Kotlin file (swap in your repo's own):
- Lint and unit tests:
./gradlew lint test - Static analysis:
./gradlew detekt(if configured)
Run ./gradlew lint test (or ktlintCheck/detekt if configured) for the changed modules, plus connectedAndroidTest when UI tests changed and a device is available. Report what ran.
Before and after: Kotlin: coroutine scopes and swallowed cancellation
Prompt card 28 · Stack: Kotlin coroutines (Android or server) · Type: Correctness review · Best model: a frontier reasoning model
What the card is for
Find GlobalScope launches, runCatching or catch (e: Exception) blocks that swallow CancellationException, blocking calls on the wrong dispatcher, and parallel work that keeps running after one branch fails.
Worked example from the card
Before
class ProfileRepository(private val api: Api, private val db: LegacyDb) {
fun refresh(userId: String) {
GlobalScope.launch {
val result = runCatching {
val profile = api.profile(userId) // suspend
val avatar = api.avatar(userId) // suspend, independent
db.save(profile.copy(avatarUrl = avatar)) // blocking JDBC
}
result.onFailure { Log.w("Profile", "refresh failed", it) }
}
}
}
Expected AI output (abridged)
- GlobalScope: the refresh outlives the screen and cannot be canceled or awaited.
Make refresh a suspend function and let the caller choose the scope.
- runCatching catches CancellationException: canceling mid-request logs a bogus
failure and continues to db.save.
- profile and avatar are independent: fetch them concurrently in coroutineScope.
- db.save blocks: run it on Dispatchers.IO (injected for tests).
After
class ProfileRepository(
private val api: Api,
private val db: LegacyDb,
private val io: CoroutineDispatcher = Dispatchers.IO,
) {
suspend fun refresh(userId: String): Result<Unit> = try {
coroutineScope {
val profile = async { api.profile(userId) }
val avatar = async { api.avatar(userId) }
val merged = profile.await().copy(avatarUrl = avatar.await())
withContext(io) { db.save(merged) }
}
Result.success(Unit)
} catch (e: CancellationException) {
throw e
} catch (e: IOException) {
Result.failure(e)
}
}
@Test
fun `cancelling refresh stops before saving`() = runTest {
val db = FakeDb()
val repo = ProfileRepository(SlowApi(delayMs = 1_000), db, StandardTestDispatcher(testScheduler))
val job = launch { repo.refresh("u1") }
advanceTimeBy(500)
job.cancelAndJoin()
assertTrue(db.saved.isEmpty())
}
Excerpt from card 28; the prompt itself is in the pack.
More Kotlin prompt cards in the pack
- Card 29: Jetpack Compose: state hoisting and recomposition
Using these rules in Cursor, Claude Code and AGENTS.md tools
Cursor
Put _base.mdc and kotlin.mdc in .cursor/rules/. Globs: build.gradle.kts, **/*.kt, **/*.kts, gradle/libs.versions.toml.
Claude Code
Root CLAUDE.md, base first. Path-scoped variant .claude/rules/kotlin.md for **/*.kt, **/*.kts; PostToolUse hook: ./gradlew ktlintFormat (if configured).
AGENTS.md tools (Codex, Gemini CLI, Aider)
Base then stack in one AGENTS.md. Gemini CLI needs context.fileName; Aider needs --read AGENTS.md. Put this file in the Gradle root, or a module such as app/ or server/. Agents use the AGENTS.md nearest to the file being edited, so a nested copy overrides a root one for that subtree.