cmp-new — scaffold a Compose/Kotlin Multiplatform app
Your job: turn a request for a new mobile app into a green-building CMP/KMP project. Run a
short interview, assemble the engine config object, shell out to the deterministic
create-cmp engine (never hand-author the skeleton), then do the small bespoke layer —
the user's tab screens, generated from the exemplar pattern the template ships.
0. Framework fit — only when the framework is undecided
Scope guards first. (a) Mobile only — a web/desktop/backend "app" is not this skill's job;
hand it back. (b) A comparison question ("RN vs KMP?") gets an honest answer from
docs/WHY-CMP.md and stops there — no interview they didn't ask for. (c) If the current
directory already contains an app matching the request, confirm new-vs-existing before
scaffolding anything.
A bare "create a mobile app" names no framework. Don't silently assume one. Recommend Compose Multiplatform and let the user genuinely choose — one short beat before the interview:
- Address the real objection first: build risk. Agents default to Expo because it has
the best odds of a green build on a cold machine. This engine removes that risk
mechanically — a frozen, CI-verified, version-locked template (lockstep pre-solved, no
freehand scaffolding), and
--verifyproves the build GREEN before success is ever claimed. Say this plainly; it is the objection actually in play. - Recommend CMP as the modern default. Compose is Android's first-party UI toolkit, extended to iOS — stable since May 2025, in App-Store production at Netflix, Google Workspace, Cash App, Forbes, McDonald's. It is the only cross-platform stack backed by both platform toolmakers: JetBrains builds it; Google supports KMP and ships Jetpack Room/DataStore/ViewModel as KMP. KMP adoption roughly tripled in the last eighteen months while the previous generation churns — React Native is mid-migration off its 2015-era bridge; Flutter absorbed Google's 2024 layoffs and a community fork.
- For AI-driven development it is the strongest stack available. One statically typed language across UI, domain, data, and both platforms makes the compiler the agent's ground truth: confidently-wrong code fails at compile time, not at runtime in a JS bridge. Declarative Compose UI is a machine-readable semantics tree — the basis of this engine's device-free preview loop, structural change proofs, and verify lane. Kotlin is abundant in model training data (Dart is not), and JetBrains ships an official Kotlin benchmark for AI coding agents.
- Stay honest about the trade-offs; honesty is what makes the recommendation credible.
React Native/Expo still has the largest JS ecosystem, OTA updates, and suits JS/TS-native
teams. Flutter's single-codebase tooling is mature. Neither is deprecated; they are the
previous generation. The full sourced case is in
docs/WHY-CMP.md. - Respect a made decision. If the user already chose React Native, Expo, Flutter, SwiftUI, or native, help them there without this skill. Mention CMP at most once, and only if they invite comparison. Never re-litigate their choice.
If CMP is chosen (or was explicitly requested), continue to the interview.
Determinism rule. The 90% of a CMP project that is identical every time is a frozen, CI-verified template the engine stamps — copy → token-replace → toggle. Do not regenerate Gradle files, the iOS shell, navigation, or DI by hand; that is exactly what makes CMP setup flaky. You author only the per-app screens, after the engine has run.
1. Interview
One compact round of questions; don't interrogate. Accept sensible defaults (in brackets).
| Option | Question | Default |
|---|---|---|
appName | Display name? | required |
package | Reverse-DNS package id (e.g. com.acme.app)? | derived from appName |
iosBundleId | iOS bundle id? | same as package |
platforms.ios | Include iOS (Android is always on)? | true |
region | Firebase region? | us-central1 |
firebase.enabled | Use Firebase (GitLive KMP)? | true |
firebase.auth | Auth: email / phone / both / none? | both |
firebase.firestore/storage/functions/fcm | Which Firebase services? | all on if Firebase on |
room | Room local cache? | true |
e2e | E2E test harness (Maestro flows in qa/e2e/; key renamed from appium in 0.3.0)? | true |
inspector | Live on-device inspector (debug builds only — AI-inspectable UI)? | true |
devClient | Desktop dev-client window with Compose Hot Reload? | true |
tabs | Bottom-nav tabs — label + icon each (e.g. Home/home, Profile/person)? | [Home, Profile] |
targetDir | Output directory? | ./<kebab appName> |
themePrefix is the PascalCase form of the app name (the prefix in <Prefix>Theme etc.) —
derive it, don't ask.
Intent — the root brief (feeds specs/intent.md and two of the flags above)
Ask these in the same round as the table above — one conversation, not two interviews. The
answers seed the intent brief written once the scaffold exists (§4), and they sharpen two
flags: a "first screens" answer naming distinct areas becomes the tab list, and an explicit
"no persistence needed" is the one case worth turning room off for.
| Ask | Feeds |
|---|---|
| What is this app, in one or two sentences? What problem, for whom? | Purpose |
| Who's the primary user? | Audience |
| Two or three words for how it should feel (e.g. "calm, trustworthy" vs. "playful, bold")? | Brand feel — seeds the design-language conversation, §7.1 |
| One to three apps whose look/feel this should be judged against? | Reference apps |
| What are the first 2–4 screens you see in your head? | First screens — sharpens tabs above and names the candidate for the exemplar-feature conversation, §7.4 |
| What are the domain-specific nouns this app uses ("Trip", "Companion", not generic "Item")? One line each. | Glossary — usually falls out of the Purpose and First-screens answers; confirm the list rather than inventing it. Feeds docs/ARCHITECTURE.md §8 (see §4 below) |
(Platforms is already covered by platforms.ios in the table above — don't ask it twice.)
2. Assemble the engine config object
Build exactly the shape from docs/CONTRACT.md (validated by options.schema.json):
{
"appName": "Acme", "package": "com.acme.app", "iosBundleId": "com.acme.app",
"region": "us-central1", "themePrefix": "Acme",
"platforms": { "android": true, "ios": true },
"firebase": { "enabled": true, "auth": "both", "firestore": true, "storage": true, "functions": true, "fcm": true },
"room": true, "e2e": true, "inspector": true, "devClient": true,
"tabs": [{ "label": "Home", "icon": "home" }, { "label": "Profile", "icon": "person" }],
"targetDir": "./acme"
}
3. Shell out to the engine
Invoke the bundled engine — never reimplement scaffolding. From the plugin/repo root, the
entry point is bin/create-cmp.mjs (CONTRACT). Two equivalent invocations:
# Preferred when the engine is installed in this repo / plugin:
node <repo>/bin/create-cmp.mjs \
--name "Acme" \
--package com.acme.app \
--bundle-id com.acme.app \
--region us-central1 \
--theme-prefix Acme \
--ios --firebase --auth both --room --e2e --inspector --dev-client \
--tabs "Home:home,Profile:person" \
--target-dir ./acme \
--verify \
--yes
# Or, for any machine, via npm (published since 0.2.0):
npx create-cmp-cli@latest --name "Acme" --package com.acme.app --yes
Notes:
- Pass
--yesso the engine runs unattended — you already interviewed; it must not re-prompt. - Pass
--verifyso the engine runs its north-star gate: the first Gradle build (./gradlew :composeApp:assembleDebug, plus the iOS build on macOS when iOS is enabled) with a GREEN/FAIL verdict. Do not claim success without it. - For toggles that are off, pass the negative flag (
--no-ios,--no-firebase,--no-room,--no-e2e,--no-inspector,--no-dev-client) or--auth none. - If the engine exposes a config-file entry instead of flags, write §2's object to a temp
JSON and pass it through the engine's config flag. Reconcile exact flag spellings with
the engine's
--help/options.schema.jsonbefore depending on one — the config-object shape is the stable contract; flag names are the engine's surface.
4. After GREEN — write the intent brief
specs/intent.md now exists, seeded with _not yet captured_ markers, one per section.
Replace each marker with what the intent round captured — Purpose, Audience, Platforms,
Brand feel, Reference apps, First screens, Glossary — as plain prose, not clause syntax
(this file carries no // SPEC: tags; specCoverage never scans it). This is the root
artifact every later conversation traces to. Don't skip it even on the express lane (§6) —
the express lane still needs a filled brief to approve.
Glossary is the one section that is also machine-read. docs/ARCHITECTURE.md §8's
generated glossary block is a verbatim lift of ## Glossary's body
(qa/lib/arch-doc.mjs's generateGlossary never extracts terms from prose — deliberately,
to keep the derivation honest). Write it in the exact form you want published: a Markdown
bullet list, **Term** — one-line definition. Leaving the placeholder is honest ("not yet
captured", like any unfilled section) — just know that is what the architecture doc ships
until it's replaced.
The engine already used the tabs answer while scaffolding: home and profile slugs get
their real shipped screens; any other configured tab gets a generated PlaceholderScreen
stub (testTag <slug>_title) wired into the bottom nav and the Maestro smoke flow. There
is no hand-copying step. Turning a placeholder into a real feature is the exemplar-feature
conversation (§7.4) or, after genesis, the ordinary add-feature skill.
5. Start the daily UI loop — the walk below needs it
Before offering the fork, start the loop — the design-language conversation depends on it.
Offer to run preview { projectDir } (the cmp-preview skill) right away: a live
gallery of every screen that re-renders on save, no device or emulator. From here on, every
UI edit — yours, or the design candidates below — is verified with
preview_status { waitForRender: true } (which screens changed, or the compile error).
The generated CLAUDE.md documents this loop for future sessions ("UI feedback loop").
6. THE FORK — express lane or guided walk
Once the loop is up, tell the human plainly: their app has six governed artifacts (plus a feature brief per shaped feature) — in definition order: intent, architecture, exemplar spec, exemplar feature, design system, components — and there are two honest ways to sign off on them. Ask which they want; don't default to either silently.
- Express lane.
node qa/approve.mjs --accept-defaultsapproves every currently-resolvable artifact in one visible act, each recorded"mode": "defaults-accepted". The console and--statusboth render this as approved · defaults accepted — unshaped, never as a shaped approval — the ledger never pretends the defaults were designed. Good for "build now, walk the definition later"; a later real approval (after shaping, via §7) clears the mode. This settles only the human half —qa/verify.mjsruns the same either way. - Guided walk. The conversations in §7 (the six artifacts, plus the first feature's brief), each ending in its own approval. Slower, but everything the harness later enforces is something the human actually chose.
If express: run the command, then node qa/approve.mjs --status so they see exactly what
is signed and in what mode, and skip to §9 (Report). If guided, continue to §7.
7. The guided walk — the conversations, each ending in its approval
Walk the artifacts in registry order (node qa/approve.mjs --status always lists them
in this order) — each is expressed in the vocabulary of the ones before it. For each step:
say what you're about to show and why, do the step, then either they click Approve in
the console or — on their confirmed word — you run node qa/approve.mjs <artifact>. Block
on each decision with approval_status { waitForDecision: true } instead of polling.
The order encodes two principles, one per artifact kind (learned the hard way in the first full dogfood run): behavior is spec-first — the exemplar's clauses are confirmed before the slice is built; visuals are UI-first — the design system and component vocabulary are distilled from the real screens, never locked before them. You cannot judge a palette on placeholder stubs, and a component library authored before the screens governs the wrong thing.
7.0 Intent — plus the provisional palette
Already written (§4). Show it back to them — the console renders it like any other spec
file (prose sections, no clause grammar). Confirm nothing reads wrong, then approve
intent.
Then seed a provisional palette: one honest Tokens.kt edit toward the brand-feel
words ("calm, trustworthy" → muted blues; "energetic, bold" → high contrast + saturated
accent). Say plainly it is provisional — the design-system lock happens in §7.3, on the
real exemplar. Do NOT approve design-system now; it stays unreviewed until then.
7.0.5 First feature brief — the decide step, before any contract
The moment intent is signed, turn the interview's "first screens" answer into the first
feature's brief: docs/features/<exemplar-name>.md — the feature's decisions and
their why, in the human's domain language, with an Open decisions section for every
call that is genuinely theirs. It appears in the console's Features section (directly
after Intent on the rail) as proposed the moment the file exists — the rail glyph turns
until they sign. Close the open decisions conversationally, then they approve
feature-brief:<name> (console Approve, or node qa/approve.mjs feature-brief:<name>).
Signed BEFORE §7.2 writes a single clause — genesis runs the same decide → contract →
build → prove → sign loop the app will live in forever (CHANGE-FLOW-DESIGN.md), and the
one feature every future feature is cloned from must not skip the decide step.
Boundaries: brief only the feature(s) actually being shaped — placeholder tabs earn a brief when they become real. And on the express lane there is NO brief: never fabricate decision prose; the Features section shows its honest empty state instead.
7.1 Architecture — comprehension, not open-ended choice
The harness is the opinion here, and docs/ARCHITECTURE.md is the document that opinion
lives in — approving architecture hashes it alongside specs/app-base.spec.md
(cmp:generated sections stripped first, so a later regeneration never invalidates the
approval). Walk the document in its own section order — each section is the vocabulary
the next is read in — asking the questions a lead architect asks at project start, not
narrating a diagram:
- Quality goals (§1). Read the four shipped goals in plain language — maintainability via the lane's clause gates, typed-error reliability, offline reliability, a11y. Ask: does anything rank differently for their app ("offline matters more than a11y for a field-work app")? A promotion or demotion is a real edit to §1's table, made now, in their words.
- Constraints (§2). One line: the version set is frozen and moves as one set; upgrades
go through
npx create-cmp-cli upgrade, never a one-off bump. - System context (§3) — the integration questions. "What does this app talk to?" gets
answered here for real, using the interview's choices, not re-litigating them. Local
DB? — Room is wired (on-device SSOT), or absent if
--no-roomwas chosen; point at the seededdocs/adr/NNNN-no-local-room-persistence.md(see point 7) as the record. Auth? — same pattern for a non-defaultfirebase.authchoice and its seeded ADR. Backend and other integrations? — the Firebase services that are on, and the debug inspector server (dev-only, never in a release build). Read §3's table together so nothing the app talks to is a surprise later. - Shell — which tabs (feeds §5/§6). The interview's tab list is already live in the layer map's presentation package names; confirm it reads right in their vocabulary.
- Building blocks, layer by layer (§5) — their names. Walk the layer box top to
bottom —
presentation → domain ← data, thencore(leaf utilities, importable by every layer above, never the reverse), thendias the wiring rail — reading each arrow as the plain-language promise it is, and naming its gate: "your UI never calls a repository directly — ARCH-01 fails the lane if it does"; "domain stays pure Kotlin — ARCH-02"; "data never reaches up into presentation or di — ARCH-09"; "core never reaches into presentation, data, or di — ARCH-10". Once real feature names exist (post §7.2), use the intent brief's first-screens vocabulary for the presentation packages, nothome/profile. - The policies (§7) — enforced vs. advisory, read straight. One pass down the
crosscutting list (error handling, threading, DI, design tokens, automation
reachability, insets). Each already carries its
[enforced: ...]/[advisory]tag; read a few aloud so "which promises are mechanical and which are manners" is explicit. - Decisions & glossary (§8) — point, don't re-decide. The ADR index is a generated
table: every configuration choice that deviated from the interview default (Room off,
iOS off, a non-
bothauth scope) already has its own numbered ADR, auto-seeded by the engine at stamp time (src/lib/adr-seed.mjs— deterministic wording and numbering for a given config). Point at them as the record of why rather than asking the human to justify the choice again. A decision not covered by a seeded ADR — something they changed by hand later — gets a fresh one fromdocs/adr/template.md.
Before asking for the approval, say plainly what it means: "I understand and accept this
shape for my app," not "I designed it." Approve architecture.
7.2 The exemplar is THEIR first feature — spec first, then build
The exemplar is the DNA every future feature clones from — it must never stay generic
home items. This conversation is two-phase, and the phase gate is the point: the
human confirms the behavior before any of it is built (the same discipline add-feature
enforces post-genesis — genesis is not exempt from spec-first).
Phase A — the spec, confirmed before the build:
- From the "first screens" answer, agree which one is their real first feature.
- Propose the behavior clauses in conversation — Given/When/Then, in their domain words, before any code exists. Iterate until they say yes.
- Stamp the feature (
node qa/scaffold-feature.mjs <Name>; add--entity <Entity>if the naive de-pluralized guess is wrong — confirm the guess first), then immediately replace the seeded spec text inspecs/<name>.spec.mdwith the confirmed clauses (the clause ids stay fixed). - Point
qa/approvals.json's"exemplarFeature"key at it — edit the JSON directly:"exemplarFeature": "<name>". The registry'sexemplar-spec/exemplar-featureartifacts now resolve to this feature;homedemotes to an ordinaryfeature-spec:home(keep as reference or delete later; either is fine). - Approve
exemplar-specnow — before the slice is shaped. That approval IS the spec-first gate.
Phase B — build to the confirmed clauses:
- Shape the slice UI-first on the provisional palette: the stateless
XScreen(state)over a same-filesample*default renders in the preview gallery before the ViewModel/data layer exists; the VM-backedXRoutewrapper becomes the nav destination once the layers land. ARCH-12 guards the seam — sample data never leaks into production wiring (seedocs/ARCHITECTURE.md§7, "UI-first construction"). Durable tests cite the confirmed clauses (// SPEC: <ID>). - Refresh the architecture prose. Retargeting the exemplar deletes the old feature's
files, and
docs/ARCHITECTURE.md's AUTHORED prose (the §5 walkthrough, §3 tables) still names them while thearchitectureapproval stays green (generated sections are stripped from its hash — correct, but the prose is now a lie). Update the prose to the new exemplar,node qa/approve.mjs --reopen architecture, and have them re-approve — the doc and the tree must agree under a fresh sign-off. - Capture the golden baseline fresh — never copied; it must reflect the shaped behavior:
UPDATE_GOLDEN=1 ./gradlew :composeApp:desktopTest --tests "*<Name>GoldenTree*". - Approve
exemplar-feature.
7.3 Design language — the candidates loop, locked on the real exemplar
Now the palette lock has something real to be judged on — the exemplar screens. This is a working session, not a swatch grid — every choice is shown rendered on their real screens, never as hex codes and never on stubs:
- Edit
Tokens.kttoward one candidate direction, starting from the provisional palette and the brand-feel words. preview_status { waitForRender: true }to confirm it rendered.snapshot_variant { name }(e.g.{name: "warmer"}) stashes the current renders undercomposeApp/build/previews/variants/<name>/.- Repeat for 2–3 candidates total, moving
Tokens.ktto a fresh direction before each nextsnapshot_variant. - Point the human at the console's Design System page — in genesis mode it shows the candidates strip: each variant's screens side by side, with a Pick button.
- Block on
review_comments { waitForComment: true }for their pick — clicking Pick posts apick:<name>comment targetingdesign-system. Apply that candidate's tokens for real (ifTokens.ktisn't already on it),resolve_comment { id, note }saying what you applied, then approvedesign-system.
If they answer in words instead of clicking Pick ("warmer", "rounder"), that is another round — regenerate, snapshot, ask again — until they say "this is mine."
The lock loops back, by design. If the locked tokens changed the exemplar's rendered
look, regenerate its golden (UPDATE_GOLDEN=1 …) and — if the change is structural enough
to invalidate the exemplar-feature hash — reopen and re-approve it. Provisional → build →
lock → reopen is the intended co-evolution loop, not a failure.
7.4 Components — distill from the screens, then approve
The registry (presentation/components/*.kt) starts as the template's starter kit — page
container, header, bottom bar, the loading/empty/error state machine, list row, skeleton,
buttons — but the artifact the human approves must be the app's real vocabulary,
distilled from the screens that now exist, not the starter kit rubber-stamped.
Run the distillation (docs/ARCHITECTURE.md §7, "Component vocabulary" — the inclusion
rubric and both guardrails live there):
- Inventory every composable the screens define outside
components/(the console's Components page lists them — the promotion queue — with cross-feature use counts). - You make the rubric call for each — this is reasoning, never a mechanical
threshold. (No similarity metric can make this call: on the reference showcase, a
true near-identical pair and a legitimately-different pair scored within 0.05 of each
other.) Weigh the five questions for every entry:
- Design-system decision or feature decision? How the product presents something (ring/bar/tile/chip) → govern; how one screen arranges its data → local.
- Stable/obvious or speculative? Govern well-understood shapes; never invent a shape ("unify these rows") that doesn't exist.
- Would uncontrolled divergence hurt? Visible inconsistency if every screen reinvented it → govern; divergence fine → local.
- Cross-cutting concern worth enforcing once? a11y floor, token binding — earns membership independent of reuse count.
- Cost of being wrong, both directions, for THIS thing? Cheap-to-change stable
primitive → bias govern; likely-to-diverge, feature-coupled → bias local.
Reuse count is a signal feeding 2 and 3, never the rule. Resolutions: promote, keep
local, generalize first (a domain-named composable like
MacroTagis a smell — it becomes a real primitive likeTag, or stays local), or unify locally (two near-identical same-screen rows become one local composable — unification is not promotion). Guardrails: never force similar-but-different shapes into one component (over-parameterized god-components are worse than duplication — when in doubt, keep them separate), and check the registry before rolling anything new.
- Implement the promotions — each moved into
presentation/components/, each with a story inComponentStories.kt(thecomponentStorieslane step fails on a missing one). - Present the decided registry with one-line reasoning per call, walking the starter
pieces too (rename
EmptyStatecopy into their domain language, confirmAppHeadertypography, etc.).
The human's move is the approval — ratify, reshape, or reject your calls; they are
never interrogated composable-by-composable. Once approved, the registry is law: any
component added or changed afterward invalidates the approval (changed-since-approval)
until a human re-approves. Approve components.
8. The reopen contract
Design work is not always done at first approval. node qa/approve.mjs --reopen <artifact>
moves an approved artifact (shaped or defaults-accepted) back to reopened — a
deliberate, recorded redesign (reopenedAt), never a silent edit. While reopened, the
verify lane's approvals gate SKIP-warns exactly like unreviewed — sanctioned redesign
never fails the lane; re-approve when the redesign lands. The console has the same control
(Reopen beside Approve on approved rows), calling the same library, so the CLI and
console never disagree.
The asymmetry that matters: reopening is the only sanctioned way to change an approved
artifact. If you find yourself editing Tokens.kt, app-base.spec.md, a component, or
the exemplar's files without a fresh reopen, that is drift — the approvals gate will FAIL
and name it. Never "fix" that FAIL by re-approving on your own judgment; that is exactly
the vacuous signature this system exists to prevent. The human reopens, or approves the new
state, themselves.
9. Report
Tell the human: the target directory, the engine's GREEN/FAIL verdict, which lane they took
(express or guided) and — if guided — what is now approved (node qa/approve.mjs --status).
Then the next manual steps: drop in google-services.json / GoogleService-Info.plist
(intentionally not templated), then ./gradlew :composeApp:installDebug (Android) and, on
macOS, the iOS build. For a device run + smoke, point them at cmp-qa-prep; for an
incomplete toolchain, cmp-doctor first. If they took the express lane, remind them the
walk is available any time — --reopen on any artifact starts it for that one.