There is no template repo behind this. A selection goes through five stages,
each one a file in packages/core/src, and a whole repo comes out the other
end. The same selection gives the same bytes every time.
The stages, in order:
registry/ -> loader -> resolver -> mergers -> compiler -> generator -> your repo
registry.ts resolver.ts merge.ts compile.ts generate.ts
Each section names the real file and function, so you can read the source alongside this page.
1. The registry is the product
registry/ holds every plugin. A plugin is a folder: a stack, a battery or a
design. The generator has no idea what your battery is called. It reads the
folder, sorts it, and merges whatever it finds.
registry/<stacks|batteries|designs>/<id>/
manifest.yaml required
template/ copied into the generated repo
agent/
rules/*.md path-scoped conventions
skills/*.md slash commands
agents/*.md subagents
hooks/*.md hook definition plus its inline script
solutions/*.md long-form docs, also published at /cookbook
design.yaml designs only: the token set
DESIGN.md designs only
Today that is 1 stack, 26 batteries and 31 designs, plus 3 presets.
loadRegistry(root) in packages/core/src/registry.ts walks that tree and
turns it into the typed Registry in types.ts. It parses the manifests and
reads markdown frontmatter through parseFrontmatter from markdown.ts. It
sorts arrays by id and normalises text (BOM stripped, CRLF folded). It is
tolerant on purpose. A
broken manifest is kept, not dropped, so validateRegistry in schema.ts can
report every problem in one pass instead of dying on the first one.
toIndex(registry) makes the RegistryIndex: manifests, per-plugin counts,
presets and the tested pairs, with no template bodies and no absolute paths.
That is what the browser gets, which is how the builder re-resolves your picks
on every click with no round trip.
2. The resolver decides what is legal
packages/core/src/resolver.ts takes a raw Selection and returns a
ResolvedPlan. It is the only stage that says no. It also runs in the browser,
so it has no filesystem, no clock and no randomness.
resolve(selection, index) runs these passes:
- Auto-add from
requires. Better Auth needs an ORM, a database and email, so picking it adds Drizzle, Neon and Resend. Each addition comes with anauto-addedexplanation and a sentence saying why, which the builder shows inline. The pass runs until nothing changes, and a cycle is reported asrequires-cycleinstead of looping. - Stack compatibility, from each manifest's
compatibleStacks. - Declared conflicts. A
conflictsentry names a plugin. Supabase Auth conflicts with Neon, so you getconflict, not a repo with two databases. - One choice per category, reported as
category-conflict. - The admin panel needs an auth provider:
admin-needs-auth. - The tested set. Covered below.
- Recommendations.
recommendsnever adds anything. It emits awarn.
The plan carries the final selection, the plugin ids in a fixed order (stack,
then batteries by category then id, then design), every explanation, the six
counts behind the counters, and ok. When ok is false, generate refuses to
run rather than write a repo that can't boot.
isSelectable(batteryId, selection, index) is the same machinery pointed
sideways. It resolves your selection with and without a battery and reports the
first blocking reason that battery adds. That is where the greyed-out options
and their reasons come from. Nothing in the UI decides what is legal on its own.
3. The mergers assemble the files
packages/core/src/merge.ts holds the ways two plugins can add to the same
output.
Token substitution. templateVars(selection) builds the token table and
substitute(text, vars) applies it to every template file, every
contributes.scripts value and every markdown body.
| Token | bun | pnpm | npm |
|---|---|---|---|
{{pm}} | bun | pnpm | npm |
{{pmx}} | bunx | pnpm exec | npx |
{{pmDlx}} | bunx | pnpm dlx | npx |
{{pmRun}} | bun run | pnpm run | npm run |
{{pmAdd}} | bun add | pnpm add | npm install |
{{tsRun}} | bun | tsx | tsx |
{{tsServer}} | bun --conditions=react-server | tsx --conditions=react-server | tsx --conditions=react-server |
{{pmx}} runs a binary the repo already installed (prisma, drizzle-kit, tsx).
{{pmDlx}} fetches a one-off tool the repo doesn't depend on (neonctl, a CLI
pinned @latest). They differ under pnpm on purpose: pnpm dlx prisma ignores
node_modules and downloads the latest release.
Plus {{projectName}}, {{design}}, {{mode}} and the link tokens. A plugin
that writes npm run dev or npx prisma in a doc fails the template lint,
because that string is wrong for two thirds of users. The docs you are reading
use the same tokens: the tab strip above each command is substitute running
three times.
JSON deep merge. deepMergeJson(base, patch) owns package.json,
tsconfig.json, .mcp.json and .claude/settings.json. Keys are sorted on the
way out, and arrays are concatenated with primitives deduped, so two plugins
adding dependencies give one stable file.
Markdown sections. appendMarkdownSection(base, heading, body) builds
CLAUDE.md a section at a time.
Slot fill. findSlots and fillSlots handle injection. Everything else is
a plain file copy with collision checks: two plugins writing the same path
raises CollisionError, not a last-writer race.
Slots
A stack template can leave a named hole:
{/* @slot providers */}
// @slot providers in TypeScript and <!-- @slot providers --> in markdown
work the same way. A battery fills one by name from its manifest:
contributes:
slots:
providers: "template/slots/provider.tsx"
Two rules keep this predictable. A slot a battery fills but nothing declares
fails bun run validate (slot-missing). It is never a silent skip. And an
unfilled slot line is deleted with its blank line, so a stack template reads
cleanly with zero batteries.
4. The compiler writes each agent target
The agent layer is written once in a neutral format (the frontmatter shapes in
docs/architecture/CONTRACT.md). compileAgentLayer in
packages/core/src/compile.ts writes it out for each target you picked. Claude
Code is always one.
| Target | Output |
|---|---|
| Claude Code | CLAUDE.md, .claude/rules/<id>.md with paths:, .claude/agents/, .claude/skills/<name>/SKILL.md, .claude/hooks/*, .claude/settings.json, .mcp.json |
| Codex | Root AGENTS.md plus a nested AGENTS.md per rule path prefix, skills in .agents/skills/<name>/SKILL.md |
| Cursor | .cursor/rules/<id>.mdc with globs:, plus one skill-<name>.mdc per skill that loads on request |
Path scoping is what gets translated. Claude Code reads a paths: list in the
rule's frontmatter, and a rule without one loads every session. Cursor reads
globs:. Codex has no globs. So the compiler takes the literal prefix of each
rule's paths and writes a nested AGENTS.md in that folder. Each one is sized
so the whole chain Codex reads fits its 32 KiB limit.
Hooks are wired in .claude/settings.json only. Each command runs its script
from $CLAUDE_PROJECT_DIR with your package manager (bun, or tsx from
node_modules for pnpm and npm), so a guard still fires after the agent runs
cd. See the agentic layer for what Codex and Cursor
do with them.
5. The generator writes the repo
generate(plan, registry) in packages/core/src/generate.ts puts it together.
Order matters. Templates land first so slot markers exist to be filled. The
merged files go on top. The content hash is taken last, over everything except
agentic.config.json, which then carries it.
Along the way it builds the files no single plugin owns:
package.jsonwith the base scripts (dev,build,start,typecheck,lint,lint:fix,format,test,test:e2e,verify,verify:hooks) plus each battery's own..env.examplefrom every battery's declared env vars.docs/onboard.mdfrom their onboarding steps, sorted byorder.CLAUDE.md, a short index that points atdocs/onboard.mdfirst.docs/solutions/seeded with each plugin's solution docs.docs/plans/with a README and a plan template.scripts/verify.tsandscripts/verify-hooks.ts, filled in for your picks.
Three small modules finish the job:
hash.ts(hashFiles): sha256 over the sorted path and content pairs.write.ts(writeRepo): writes to a folder, for the CLI and the matrix.zip.ts(zipRepo): builds the zip for the web download.
The tested set
The resolver refuses any battery pair that is not in registry/tested.yaml, a
sorted list of pairs. A set that exactly matches a preset skips the pair check,
because the matrix runs every preset whole. Everything else is checked pair by
pair, and a missing pair is an untested error, not a warning.
So the promise is per pair. Pick three batteries and each of the three pairs has passed the matrix. That exact trio may never have run together.
Only bun run matrix writes that file. For each combination it generates the
repo outside this monorepo, installs it, typechecks it, lints it and runs its
unit tests. With --build it also runs next build with no env set, boots the
app and loads every static route. --update-tested and --add-tested need
--build, and they write back only the pairs that passed. Edit the file by hand
and you claim a check that never happened. The resolver believes it completely,
and someone gets a repo that doesn't boot.
Two lanes go further than booting:
--dbgives each combo a fresh database on local Postgres. It runs the repo's own migrations and seeds (each seed twice, to prove it is safe to rerun), builds with that env and loads every route against the live database.--browserthen runs the repo's own Playwright specs against that server: sign-up and sign-in, the app shell,/pricingand checkout with no provider keys, and the admin pages. A spec that has no environment to run in skips with a reason, and the lane prints every reason.
With either lane the matrix adds 8 lane combos, which between them cover every
auth, ORM, database and payments battery. No Docker: Neon runs through the
repo's own db:proxy and Supabase Auth through its GoTrue and PostgREST
binaries.
shell
bun run matrix --db --browser --preset indie-saasIf the pair you want is greyed out, it isn't certified yet. From a clone of the generator (it runs on Bun), run the matrix for that battery's pairs and commit the result:
shell
bun run matrix --battery <id> --pairs --build --add-testedLeave out --pairs and no pair runs, so nothing new gets certified.
Determinism, and why it replaces sync
The output has no timestamps, no random ids and no absolute paths. Keys are
sorted before they are written, and files are sorted by path.
agentic.config.json in the generated repo records the full selection, the
plugin ids, the counts and the sha256 of the output.
That file is the V1 answer to "how do I get updates". There is no sync. Months
later you regenerate from the same agentic.config.json on a newer registry and
diff it against your repo. You see exactly what changed and take what you want.
bun run snapshot in this repo generates every preset twice in one process and
fails if the hashes differ, so the property that makes this work is itself
tested.
Read it yourself
| File | What it owns |
|---|---|
packages/core/src/types.ts | Every shared type. Never redefined elsewhere. |
packages/core/src/registry.ts | loadRegistry, toIndex |
packages/core/src/schema.ts | validateManifest, validateRegistry |
packages/core/src/resolver.ts | resolve, isSelectable, defaultSelection, selectionFromPreset |
packages/core/src/merge.ts | substitute, deepMergeJson, appendMarkdownSection, fillSlots |
packages/core/src/compile.ts | compileAgentLayer, hookCommand |
packages/core/src/generate.ts | generate |
packages/core/src/markdown.ts | parseFrontmatter, serializeFrontmatter, extractScriptBlock |
packages/core/src/hash.ts · write.ts · zip.ts | hashFiles, writeRepo, zipRepo |
Next: the agentic layer, which is what all of this exists to install.