---
name: android-design-tokens-codegen
description: "A single-source JSON to typed-Kotlin codegen pipeline for design tokens, UI-test ids, analytics events and deeplink manifests, run as Gradle tasks with a --check CI freshness gate. Use when a design system or cross-platform app needs one source of truth that generates typed code."
metadata:
  risk: safe
  source: multi-agent-pipeline
  date_added: "2026-09-21"
---

# Android Design Tokens Codegen

A pipeline that treats one or more JSON files as the single source of truth and
generates typed Kotlin from them at build time: design tokens, UI-test
identifiers, analytics event definitions, and deeplink manifests. Each generator
is a thin Gradle task that wraps a generator binary or script, runs before
compilation, is incremental, and exposes a check mode so CI can fail when the
committed output is stale. Targets AGP 8.x+, Gradle 8.x+, and KSP-era Kotlin
(2024-2025).

Full, copy-ready Gradle and Kotlin for every section below lives in
[references/patterns.md](references/patterns.md). Load that file when you need a
complete task, plugin, or generator example to adapt.

## Contents

- [Why single-source codegen](#why-single-source-codegen)
- [Pipeline shape](#pipeline-shape)
- [The Gradle task wrapper](#the-gradle-task-wrapper)
- [Wiring into preBuild](#wiring-into-prebuild)
- [Check mode and the CI contract](#check-mode-and-the-ci-contract)
- [Caching and incrementality](#caching-and-incrementality)
- [The four generated domains](#the-four-generated-domains)
- [Do's and Don'ts](#dos-and-donts)
- [Review Checklist](#review-checklist)
- [Ownership map](#ownership-map)

## Why single-source codegen

Constants that describe the same thing in more than one place drift: a color
value edited in a stylesheet but not in Kotlin, a test tag renamed in the UI but
not in the test, an analytics event whose name no longer matches the dashboard,
a deeplink path the app parses one way and the marketing team links another.
Hand-written constant files are the drift surface.

A codegen pipeline removes the surface. The JSON is authored (or exported from a
design tool) once; a generator turns it into typed Kotlin; nothing downstream is
hand-edited. Because the output is typed, a removed or renamed token becomes a
compile error at every call site rather than a silent runtime miss. Reviewers
diff the JSON, not the generated Kotlin.

In a cross-platform shop the same JSON feeds every platform's generator, so the
guarantee widens: one schema, many typed outputs, no per-platform divergence.
The mechanism below is platform-neutral in its input and Android-specific only
in what it emits.

## Pipeline shape

```
tokens.json ─┐
test-ids.json ┤        ┌─ generateDesignTokens ─┐
analytics.json┤─ JSON ─┤─ generateTestIds       ├─ typed Kotlin ─ compile
deeplinks.json┘  (SoT) └─ generate...           ┘   (build dir)
```

Each JSON file is an input; each generator is one Gradle task; each task writes
Kotlin into a generated-source directory under the build folder, which is added
to the Kotlin source set. Generated code is not committed unless a check-mode
contract requires it (see below); either way it is never hand-edited.

## The Gradle task wrapper

Keep the generator itself out of the build script. The task's only job is to
declare inputs and outputs, then invoke a binary or script. A minimal typed task
looks like this:

```kotlin
abstract class GenerateFromSchemaTask : DefaultTask() {
    @get:InputFile
    @get:PathSensitive(PathSensitivity.RELATIVE)
    abstract val schema: RegularFileProperty

    @get:OutputDirectory
    abstract val outputDir: DirectoryProperty

    @get:Input
    abstract val checkMode: Property<Boolean>

    @get:Inject
    abstract val execOps: ExecOperations

    @TaskAction
    fun generate() {
        execOps.exec {
            executable = "codegen"
            args(
                "--schema", schema.get().asFile.absolutePath,
                "--out", outputDir.get().asFile.absolutePath,
            )
            if (checkMode.get()) args("--check")
        }
    }
}
```

Register one instance per domain and point each at its own schema and output
directory. The generator binary can be a bundled JAR run via `javaexec`, a CLI
on the path, or a `JavaExec`/`WorkAction`; the wrapper contract is identical.
Full registration, `WorkAction` isolation, and `javaexec` variants:
[references/patterns.md#task-wrapper](references/patterns.md#task-wrapper).

## Wiring into preBuild

Generation must finish before Kotlin compiles. Depend on the task from
`preBuild` and register its output as a generated source directory so the
compiler and the IDE both see it:

```kotlin
val generateTokens = tasks.register<GenerateFromSchemaTask>("generateDesignTokens") {
    schema.set(layout.projectDirectory.file("schema/tokens.json"))
    outputDir.set(layout.buildDirectory.dir("generated/source/tokens"))
    checkMode.set(providers.gradleProperty("codegen.check").isPresent)
}

androidComponents.onVariants { variant ->
    variant.sources.java?.addGeneratedSourceDirectory(
        generateTokens,
        GenerateFromSchemaTask::outputDir,
    )
}

tasks.named("preBuild").configure { dependsOn(generateTokens) }
```

`addGeneratedSourceDirectory` is the modern AGP variant API; it wires the task
dependency and the source set in one call. Prefer it to manually mutating
`sourceSets`. Full multi-domain wiring and the KSP-consumer ordering note:
[references/patterns.md#prebuild-wiring](references/patterns.md#prebuild-wiring).

## Check mode and the CI contract

The build must fail when someone edits a schema and forgets to regenerate, or
edits generated Kotlin by hand. Give every generator a `--check` (dry-run) mode:
it regenerates into a temp location, compares against what is on disk, and exits
non-zero on any difference instead of writing.

Two workable contracts, pick one per repo:

1. Output committed. Generated Kotlin lives in `src` and is tracked. CI runs the
   generators in check mode; a diff means the commit is stale and the build
   fails. Contributors run the plain generate task and commit the result.
2. Output not committed. Generated Kotlin lives only in the build dir and is
   produced on every build. CI still runs check mode against a schema-derived
   fixture to prove the generator is deterministic and the schema is valid.

Contract 1 is the common choice because it keeps generated code reviewable and
makes the failure legible ("run ./gradlew generateDesignTokens and commit").
Wire the check task into the verification lifecycle so it runs in CI:

```kotlin
val checkCodegen = tasks.register("checkCodegenFresh") {
    dependsOn(generateTokens, generateTestIds, generateAnalytics, generateDeeplinks)
}
tasks.named("check").configure { dependsOn(checkCodegen) }
```

Determinism is a hard requirement for check mode: stable key ordering, fixed
formatting, no timestamps, no absolute paths in the output. A non-deterministic
generator makes check mode flap. Full check-mode task, the failure message
pattern, and a determinism checklist:
[references/patterns.md#check-mode](references/patterns.md#check-mode).

## Caching and incrementality

Declaring `@InputFile` and `@OutputDirectory` (as above) is what lets Gradle
skip a generator when nothing changed and pull the result from the build cache
when it did before. With the inputs and outputs declared, the task is up-to-date
whenever the schema and generator version are unchanged, so a normal build pays
for generation only on the first run and after a schema edit.

Two rules keep this honest:

- Every input the output depends on is a declared input. If the generator's own
  version can change the output, declare it as an `@Input` (a version string or
  the generator artifact as an `@Classpath` input) so a generator upgrade
  invalidates the cache instead of serving stale output.
- Nothing outside `@OutputDirectory` is written. Side-writes defeat
  up-to-date checks and corrupt the cache.

Mark the task cacheable and keep `PathSensitivity.RELATIVE` on the schema so the
cache key is portable across machines and CI checkouts. `@CacheableTask`,
`@Classpath` generator input, and a `gradle.properties` snippet enabling the
build and configuration caches: [references/patterns.md#caching](references/patterns.md#caching).

## The four generated domains

The same wrapper shape serves four schema families. They differ only in the JSON
they read and the Kotlin they emit; keep one generator and one task per family
rather than one mega-generator, so a stale check names the domain that drifted.

- Design tokens. Colors, spacing, typography, radii, elevation as typed
  constants or a semantic data class. The generated data class is consumed by
  the Theme layer (see ownership map), not read directly by feature code.
- UI-test identifiers. One typed constant per test tag, referenced by both the
  `Modifier.testTag` call site in the UI and the test that asserts on it, so a
  rename is a compile error on both sides.
- Analytics events. Event name plus its typed parameter set, so tracking calls
  cannot pass an unknown event or misspell a parameter key.
- Deeplink manifests. The route paths and their typed parameters the app parses,
  generated from the same manifest that documents them, so parser and
  documentation cannot diverge.

Neutral emit shapes for each domain (a token data class, a test-tag object, a
sealed analytics event, a deeplink route enum) are in
[references/patterns.md#generated-shapes](references/patterns.md#generated-shapes).
Treat those as illustrative structure, not a prescribed token set.

## Do's and Don'ts

- Do keep the generator binary versioned and declared as a task input.
- Do make output deterministic; check mode depends on it.
- Do run check mode in CI on the `check` lifecycle.
- Do keep one generator per domain.
- Don't hand-edit generated Kotlin; edit the schema and regenerate.
- Don't put generator logic inline in `build.gradle.kts`; wrap a binary.
- Don't write outside the declared `@OutputDirectory`.
- Don't emit timestamps, absolute paths, or unstable ordering.
- Don't let a generator reach the network at build time; the schema is local.

## Review Checklist

- [ ] Each generator is a typed task with declared `@InputFile`/`@OutputDirectory`.
- [ ] Output directory registered via `addGeneratedSourceDirectory`.
- [ ] `preBuild` depends on every generator.
- [ ] `--check`/dry-run mode exists and is wired into `check` for CI.
- [ ] Generator version is a declared task input (cache invalidates on upgrade).
- [ ] Output is deterministic (stable ordering, no timestamps/paths).
- [ ] `@CacheableTask` with `PathSensitivity.RELATIVE` on schema inputs.
- [ ] One generator per domain (tokens, test-ids, analytics, deeplinks).
- [ ] No hand-edits to generated Kotlin.

## Ownership map

This skill owns the single-source-JSON to typed-Kotlin generation mechanism and
its Gradle/CI wiring. It does not cover:

- Consuming the generated semantic token data class in Compose (Theme accessor,
  `CompositionLocal`, dynamic color) -> `compose-components`.
- General convention-plugin and version-catalog build setup -> `gradle-kotlin-dsl`.
- Build-failing quality gates in general (lint, detekt, coverage thresholds) ->
  `android-build-quality-gates`.
