Android has more ways to wire the same feature than almost any other mobile stack. State can live in a ViewModel, a remember block, or leak into a Composable directly. Dependency injection can go through Hilt, Koin, or manual constructor passing. Annotation processing can run on KAPT or KSP, and the two aren’t interchangeable once a project has picked one. None of this fails the build — it compiles, the app runs, the demo works — and that’s exactly the problem for an AI agent working without a CLAUDE.md: every valid-looking option is on the table, so Claude Code picks whichever one shows up most in its training data, not whichever one your project actually uses.
We pulled apart the CLAUDE.md guidance already floating around for Android — most of it stops at “use MVVM, use Hilt, use Compose” and calls it done. That’s the easy 80%. The parts that actually cause rework in code review are narrower and more specific: state hoisting inside Composables, which of Hilt’s seven-plus annotations goes where, KSP’s non-negotiable replacement of KAPT, and — the part almost nobody covers — what happens to a single CLAUDE.md once your Android project crosses 15-20 Gradle modules. This guide covers all four, plus a template built for Kotlin 2.4’s K2-only compiler and the Compose 1.12 (August 2026) release.
Why Android Needs More Guidance Than Most Frameworks
Compose’s declarative model, Kotlin’s expressive type system, and Gradle’s module system are each individually flexible — that flexibility compounds when an agent has to make decisions across all three at once.
State hoisting is invisible until it’s a performance bug. A Composable that reads a ViewModel directly instead of receiving state as a parameter still renders correctly. It just recomposes far more often than it needs to, and that only shows up as jank under load — long after the code has been merged and the original author has moved on.
Hilt has more annotations than any comparable DI framework, and several of them are only valid on specific Android component types. @AndroidEntryPoint on a ViewModel compiles as a general annotation but is simply wrong — the correct one is @HiltViewModel — and nothing short of a runtime crash or an explicit rule catches that mistake.
KAPT and KSP are not interchangeable, but they produce nearly identical generated code, so an agent that mixes them in a build.gradle.kts produces a project that builds slowly and inconsistently rather than one that fails outright.
Gradle module boundaries aren’t enforced by the language. Nothing stops a :feature:profile module from importing an internal class from :feature:settings except a rule someone wrote down — and if that rule lives only in a senior engineer’s head, Claude Code has no way to know it exists.
Complete CLAUDE.md Template for Android + Kotlin Projects
This targets Kotlin 2.4.x (K2-only from 2.4 onward — there is no K1 fallback) with Jetpack Compose 1.12 / BOM 2026.08.00. Adjust the specifics to match your libs.versions.toml.
# Android Project: [ProjectName]
## Build & Run
- Build: `./gradlew build`
- Assemble debug APK: `./gradlew assembleDebug`
- Unit tests (JVM, fast): `./gradlew testDebugUnitTest`
- Instrumented tests (device/emulator, slow): `./gradlew connectedDebugAndroidTest`
- Lint: `./gradlew lintDebug`
- Single module build: `./gradlew :feature:profile:build`
## Language & Compiler
- Kotlin 2.4.x — K2 compiler only, there is no `-language-version=1.9` fallback
- Compose compiler plugin version must match the Kotlin version in `libs.versions.toml` exactly
- Jetpack Compose BOM: check `libs.versions.toml` for the current pin (this project targets 2026.08.00 / Compose 1.12)
- compileSdk 37 minimum for Compose 1.12 and AGP 9.1.1+
## Architecture
- MVVM + unidirectional data flow: View → ViewModel → Repository → DataSource
- UI state: sealed interface/class per screen, never separate Boolean flags for loading/error/success
- Dependency injection: Hilt only — no Koin, no manual constructor wiring in production code
- Navigation: Compose Navigation with type-safe routes, one navigation graph per feature module
## State Management
- StateFlow for ViewModel → UI (one per screen, exposed as a single UI state object)
- SharedFlow for one-shot events (navigation, snackbars) — never StateFlow for events
- viewModelScope for ViewModel coroutines; lifecycleScope only in Activity/Fragment, and only when a ViewModel genuinely can't own the work
- Collect with `collectAsStateWithLifecycle()` in Composables — never `collectAsState()`
## Compose Rules (state hoisting is non-negotiable)
- State lives in the ViewModel or the highest Composable that needs it — never read a ViewModel inside a leaf Composable
- Pattern: Screen Composable owns the ViewModel; everything below Screen receives state + callbacks as parameters
- Provide stable `key` values in every `LazyColumn`/`LazyRow`/`LazyGrid` items block
- Wrap event callbacks in `remember { }` when passed multiple levels deep
- Use `remember { derivedStateOf { } }` for any computed value read during composition — never compute it inline
## Hilt (do not confuse these)
- `@HiltAndroidApp` — Application class, one per project
- `@AndroidEntryPoint` — Activity, Fragment, Service, BroadcastReceiver — never ViewModel
- `@HiltViewModel` — ViewModel, always paired with `@Inject constructor(...)`
- `@Inject` constructor injection — default for anything you own
- `@Provides` in an `@Module` — for types you don't own (Retrofit, Room database instance)
- `@Binds` in an abstract `@Module` — for binding an implementation to an interface
- Every `@Module` needs an explicit `@InstallIn(...)` — never leave it off
## Room / Persistence
- KSP for annotation processing — never KAPT (`ksp("androidx.room:room-compiler:...")`, not `kapt(...)`)
- DAOs: suspend functions for one-shot writes, `Flow<T>` return type for observed reads — never `LiveData` in new DAOs
- Type converters declared once, globally, and registered on `@Database` via `@TypeConverters` — not duplicated per entity
- `@Transaction` on any DAO method that touches more than one table
## Gradle & Modules
- Version catalog (`libs.versions.toml`) is the single source of truth — never hardcode a version string directly in a `build.gradle.kts`
- A feature module never imports another feature module's internal package — only its public API surface (typically a `:feature:x:api` module if the project separates api/impl)
- Do not add a new Gradle module without an explicit instruction — propose it first
## Testing
- Unit tests: JUnit 5 + MockK for ViewModels and Repositories, no Android framework dependency
- Compose UI tests: `createComposeRule()`, test by semantics (`onNodeWithText`, `onNodeWithContentDescription`) — never by internal state
- Fakes over mocks for Repository interfaces in ViewModel tests where a fake is cheap to maintain
State Hoisting: The Rule That Prevents the Most Rework
If your CLAUDE.md enforces one Compose rule, make it this one — it’s the pattern Claude Code violates most often by default, because reading a ViewModel inside a small Composable is the shortest path to a working screen.
// Bad — reads the ViewModel directly inside a leaf Composable
@Composable
fun UserGreeting(viewModel: UserViewModel = hiltViewModel()) {
Text(text = "Hi, ${viewModel.userName}")
// Recomposes on ANY change to UserViewModel's exposed state,
// even fields this Composable doesn't display
}
// Good — receives exactly what it needs
@Composable
fun UserGreeting(name: String) {
Text(text = "Hi, $name")
// Recomposes only when `name` itself changes
}
The bad version compiles, renders correctly, and passes a manual test. It fails you once the ViewModel starts exposing more state — a loading flag, a list of notifications, anything — because every one of those changes now triggers recomposition of a Composable that only displays a name. State hoisting means the smallest Composables receive plain values and lambdas; only the screen-level Composable talks to the ViewModel. It’s the same principle as “pure functions are easier to test,” applied to a UI tree instead of a call stack.
The Hilt Annotation Maze
Hilt’s annotation set is the single most common source of Claude Code mistakes we see in Android CLAUDE.md gaps, because several annotations look interchangeable and are not.
// Bad — @AndroidEntryPoint compiles on a ViewModel but is the wrong annotation
@AndroidEntryPoint
class UserViewModel @Inject constructor(
private val userRepository: UserRepository
) : ViewModel()
// Good — @HiltViewModel is the only annotation Hilt recognizes for ViewModels
@HiltViewModel
class UserViewModel @Inject constructor(
private val userRepository: UserRepository
) : ViewModel()
The failure mode here isn’t a compile error — @AndroidEntryPoint is a real Hilt annotation, so the class compiles fine. It fails at the point Hilt tries to inject the ViewModel into a Composable via hiltViewModel(), with an error that doesn’t obviously point back to “wrong annotation on the class.” Listing all six-plus Hilt annotations explicitly in CLAUDE.md, with which component each one is valid for, removes the guesswork entirely.
KSP, Not KAPT — Say It Once, Explicitly
KAPT and KSP both generate code from the same annotations (@Entity, @Dao, @HiltViewModel), so an agent working from older training data will reach for KAPT without any signal that your project has already migrated. The two can technically coexist in one build.gradle.kts, which means the mistake doesn’t fail the build — it just makes it slower and leaves two annotation-processing pipelines running for no reason.
This project uses KSP exclusively for annotation processing (Room, Hilt).
Never add a `kapt(...)` dependency or the `kotlin-kapt` plugin — use `ksp(...)` instead.
State the processor once, at the top of CLAUDE.md, next to the Kotlin version line. It’s a one-line fix for a mistake that otherwise shows up as an unexplained multi-minute regression in Claude Code’s own build-verification loop.
The Multi-Module Problem: CLAUDE.md Doesn’t Scale on Its Own
This is the part most Android + Claude Code guides skip, and it’s the one that actually matters once a project grows past a handful of feature modules. A single root CLAUDE.md works fine for a small app. Past roughly 15-20 Gradle modules, a flat file that tries to describe every module’s conventions in one place stops being something Claude Code can act on reliably — the relevant rule for the module you’re editing is buried in a file describing twenty other modules it isn’t touching.
The fix is the same one that works for large monorepos in other stacks: a root CLAUDE.md for project-wide rules (Kotlin version, DI framework, testing conventions, module-boundary policy), and a lightweight CLAUDE.md inside each feature module that only states what’s different.
# :feature:checkout/CLAUDE.md
Inherits root CLAUDE.md. Overrides:
## Module-Specific
- This module owns payment state — never let :feature:cart read PaymentViewModel state directly
- Uses a local Room database (CheckoutDatabase) separate from the app-level database
- Network calls go through :core:payments-api only, never a direct Retrofit instance
Claude Code reads the nearest CLAUDE.md up the directory tree when working inside a module, so a module-level file doesn’t require re-stating the whole project’s rules — it inherits the root file and only needs to name what’s genuinely local. This keeps each file short enough to actually be read in full, instead of a 500-line root file where the one relevant rule for :feature:checkout is easy to miss.
Hook-Driven Verification, Tuned for Gradle’s Slower Loop
A full ./gradlew build after every edit is too slow to run on every tool call — Gradle daemon startup plus a full module graph evaluation can take well over a minute even with the build cache warm. The pattern that works: a fast compile-only check on PostToolUse, and the real test suite on Stop.
{
"hooks": {
"PostToolUse": [
{
"matcher": "Edit|Write",
"hooks": [
{
"type": "command",
"command": "./gradlew compileDebugKotlin -q"
}
]
}
]
}
}
{
"hooks": {
"Stop": [
{
"hooks": [
{
"type": "command",
"command": "./gradlew testDebugUnitTest -q"
}
]
}
]
}
}
Keep instrumented tests (connectedDebugAndroidTest) out of both hooks — they need a running device or emulator that Claude Code can’t reliably guarantee is available, and they’re slow enough to break the feedback loop the hooks exist to preserve. Run them manually, or gate them behind CI instead.
Kotlin 2.4 and Compose 1.12: What Changed for CLAUDE.md
Kotlin 2.4 made K2 the only supported compiler frontend — there’s no more -language-version=1.9 escape hatch, which matters if Claude Code ever suggests a workaround it remembers from a K1-era Stack Overflow answer. If a Kotlin compiler error looks unfamiliar, it’s worth telling Claude Code explicitly that K2 is mandatory rather than letting it hunt for a K1 flag that no longer exists.
Compose 1.12 (the August 2026 release, BOM 2026.08.00) raises the compileSdk floor to API 37 and requires AGP 9.1.1 — a project that hasn’t bumped both will fail to resolve the new BOM, and the error Gradle produces doesn’t always make that obvious. It’s worth stating the minimum compileSdk/AGP pair directly in CLAUDE.md so Claude Code doesn’t propose a BOM bump without also flagging the AGP dependency. The release also deprecates Modifier.onFirstVisible() in favor of Modifier.onVisibilityChanged() — if your project still has the old modifier in code Claude Code touches, it’s a easy one-line note to add so the agent migrates it instead of copying the deprecated pattern into new code.
AGENTS.md Compatible Version
# AGENTS.md — Android / Kotlin Project
## Commands
- build: `./gradlew build`
- test (unit): `./gradlew testDebugUnitTest`
- test (instrumented): `./gradlew connectedDebugAndroidTest`
- lint: `./gradlew lintDebug`
## Critical Rules
1. Kotlin 2.4.x, K2 compiler only — no K1 fallback
2. KSP for all annotation processing — never KAPT
3. Compose: hoist state to the screen level, leaf Composables receive params + callbacks only
4. Hilt: @HiltViewModel for ViewModels, @AndroidEntryPoint for Activity/Fragment/Service — never swap these
5. StateFlow for ViewModel → UI state, SharedFlow for one-shot events
6. collectAsStateWithLifecycle() in Composables, never collectAsState()
7. Version catalog (libs.versions.toml) is the only place dependency versions are declared
8. Feature modules never import another feature module's internal package
## Testing
- Unit tests: JUnit 5 + MockK, no Android framework dependency
- Compose UI tests: test by semantics (onNodeWithText/onNodeWithContentDescription), not internal state
Common AI + Android Mistakes to Watch For
Mixing KAPT and KSP in the same module. Usually happens when Claude Code adds a new library that only documents its own KAPT setup — check the library’s KSP support before letting the suggestion through.
collectAsState() instead of collectAsStateWithLifecycle(). The former keeps collecting in the background even when the screen isn’t visible, which wastes resources and can produce state updates for a UI that isn’t on screen. It’s an easy substitution for an agent to make because both compile identically.
Passing a whole UiState object down through three levels of Composables instead of destructuring what each level actually needs. This defeats state hoisting even when the top-level pattern looks correct, because every Composable in the chain still recomposes on any UiState field change.
New Gradle module added without being asked. Splitting a growing feature into its own module is sometimes the right call, but it’s an architectural decision with build-time and API-surface consequences — it shouldn’t happen as a side effect of a feature request.
Room DAO returning LiveData in a Compose-only project. LiveData still works, but it requires an extra conversion (.asFlow() or observeAsState()) to use inside Compose. In a project that’s already all-in on Flow and collectAsStateWithLifecycle(), a new DAO returning LiveData is inconsistent with everything around it.
State hoisting, the Hilt annotation set, and KSP-vs-KAPT all follow the same shape: multiple technically-valid options, only one of which matches your project, and no compiler error to catch the wrong choice. The module-level CLAUDE.md pattern follows a different shape — it’s not about picking the right option, it’s about making sure the right rule is visible from wherever Claude Code happens to be working once the project outgrows a single flat file.
Browse a real Jetpack Compose rules file in our gallery, or the Kotlin Ktor backend rules if your project pairs a Kotlin backend with the Android client.
FAQ
Does Claude Code know Compose 1.12’s new APIs (Mesh Gradients, Grid named areas, Credential Manager integration) automatically?
Not reliably if your training-data cutoff predates August 2026. If your project uses any of these — MeshGradientPainter, the experimental Grid component’s named areas, or Compose’s new Credential Manager integration for passkey prompts — name the API and the BOM version explicitly in CLAUDE.md rather than assuming Claude Code will infer it from the dependency version alone.
Should CLAUDE.md go in the root or in each feature module?
Root is enough until the project has enough modules that a flat file stops being something you can read end-to-end in a minute — in practice, that’s roughly 15-20 modules for most teams. Past that point, a lightweight per-module CLAUDE.md that only states what differs from the root file (see the multi-module section above) keeps each file short enough to actually be useful.
Is Hilt still the right call in 2026, or should new projects use Koin?
Hilt remains the more common choice for Compose-first Android projects because of its compile-time safety and first-party Jetpack integration; Koin’s runtime DI trades that safety for less boilerplate. Whichever you pick, state it explicitly in CLAUDE.md — the annotation maze in this guide is Hilt-specific and doesn’t apply to Koin’s get()/inject() pattern.
Can this template work with Cursor or GitHub Copilot instead of Claude Code?
The AGENTS.md version above is tool-agnostic. The Compose, Hilt, and KSP rules aren’t Claude-specific — any AI coding tool that reads a project convention file benefits from the same explicit annotation and state-hoisting guidance.