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

First rule of each section in kotlin.mdc, quoted as shipped.

Mistakes these rules are written to prevent

Source: “Pitfalls to avoid”.

Checks the agent runs before it says done

Example commands from the Kotlin file (swap in your repo's own):

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

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.

Related stacks

← All stacks