ποΈ starter is an opinionated monorepo template for serious TypeScript with Effect and AI coding agents. π One architecture, zero knobs, and mechanical gates that reject the slop agents produce when left unconstrained. π Built for engineers accountable for codebases where AI writes the commits.
AI coding agents produce TypeScript that compiles cleanly and passes shallow unit tests while quietly violating foundational architecture: ambient side effects inside decision logic, unchecked type assertions (as Type), and mock-heavy test suites that mask runtime breakage.
starter establishes an uncompromising substrate. Invariants are not aspirational guidelines or doc comments; they are enforced mechanically by linter rules, complexity ceilings, mutation test floors, and continuous integration gates.
| Concern | The Naive AI-Assisted Default | The Endgame Architecture (starter) |
|---|---|---|
| Domain Logic | β Interleaved I/O, clocks, random generators, and mutations | β
Pure functions returning tagged Decision or Refusal unions |
| Branching | β Sprawling nested if/else and procedural loops |
β
Cyclomatic complexity 1 via exhaustive pattern matching (Match) |
| Boundary Data | β Unchecked casts (as unknown as Type, @ts-ignore) |
β
Strict Schema.decode transforming raw bytes into branded types |
| Test Verification | β Mock-heavy tests pinning internal implementation | β
100% mutation kill floor (Stryker) and property-based tests |
| Configuration | β Dozens of toggles that let agents bypass strictness | β Zero knobs β one proven opinionated toolchain end to end |
| Package Entries | β Star exports (export *) hiding dependency graphs |
β Explicit named re-exports enumerated one line per symbol |
| Refactoring | β Patching around rotten legacy modules | β Delete-first rebuild with published observable pinning |
Every external interaction in a starter project follows the I/O Sandwich:
read (impure) βββΊ decode (pure) βββΊ decide (pure) βββΊ shape (pure) βββΊ write (impure)
- π₯
readβ Gathers raw input from ports and external systems. - π
decodeβ Validates unvalidated input into branded domain types using Schema. - π§
decideβ Executes domain logic with cyclomatic complexity 1 (zero I/O, zero ambient state). - π¦
shapeβ Builds pure output documents and domain events from the decision. - π€
writeβ Persists changes, emits domain events, or returns responses.
starter wires a modern, fast, and type-safe toolchain across the workspace:
| Tool | Role & Configuration |
|---|---|
| β‘ pnpm Workspaces | Strict workspace dependency management with catalog versioning (pnpm-workspace.yaml) |
| ποΈ Turbo | High-performance task pipeline with cached builds, tests, and lint runs |
| π‘οΈ Effect 4 | The standard functional effect system |
| π oxlint | Rust-based linter enforcing strict TypeScript rules and the house presets |
| π¨ dprint | Fast, deterministic code and markdown formatting (dprint.json) |
| π§ͺ Vitest | Fast unit and integration test runner with TypeScript support |
| π¬ Stryker | Mutation testing ensuring tests fail when bugs are introduced |
| π Changesets | Automated versioning and changelogs, released as git tags via shared tooling |
| πͺ Husky & Commitlint | Git hooks enforcing conventional commit standards |
| π³ Worktrunk Scripts | Deno-powered git worktree lifecycle hooks for isolated agent work |
The repository is structured into two workspace roots defined in pnpm-workspace.yaml:
.
βββ packages/ # Reusable libraries, engines, and domain cores
βββ apps/ # The Worker and its end-to-end journeys
βββ repos/ # Vendored subtrees (constitution, worktrunk-scripts)
βββ docs/ # Solutions, tooling decisions, and plans
apps/siteβ The one Cloudflare Worker: a TanStack Start site that calls its Worker through effect/rpc (one RpcGroup served at/api/rpc, a typed RpcClient in the page) over one D1 database, defined and deployed with Alchemy.pnpm devemulates the database locally; thehealthprocedure probes the database and answersokonly when the pure decisioncheck-health.workflow.tsfinds the probe answered, refusing withDatabaseUnreachableotherwise.apps/site-e2eβ The end-to-end journeys that run againstpnpm dev(pnpm journeys).
Click the Use this template button on GitHub, or create a repository via the GitHub CLI:
gh repo create my-effect-project --template systemfsoftware/starter --public
cd my-effect-projectInstalls, builds, tests and git hooks run their dependency code inside a deny-by-default sandbox, never directly on your machine. The Nix dev shell pins the toolchain and carries the sandbox (prerequisites):
nix develop # or: direnv allow
pnpm bootstrappnpm build
pnpm check:cipnpm dev # the whole app at http://localhost:1337, Alchemy's local emulation, no cloud
pnpm journeys # the end-to-end journeys in a real browser against pnpm devThe site ships one example feature, a guestbook at /guestbook: a pure decision (sign-guestbook.workflow.ts, built with Workflow.make) trims a name and a message and refuses them with typed errors; the RPC procedures sign and list write and read entries in D1, with the decision's tagged refusals as sign's error schema; and the page shows the entries or the refusal. Any visitor can flag or vouch for an entry: a second decision (moderate-guestbook-entry.workflow.ts) applies the event to the entry's stored state (Visible, Flagged or Hidden) with the pure transition of a machine from our XState fork, and refuses an event the state can't take. The moderate procedure writes the new state only if the row still holds the state it read, and list never returns a Hidden entry. Everything it owns lives in apps/site/src/features/guestbook and apps/site-e2e/tests/features/guestbook.
To remove it:
- Delete
apps/site/src/features/guestbook. - Delete
apps/site-e2e/tests/features/guestbook. - Delete the route file
apps/site/src/routes/guestbook.tsx. apps/site/src/api/site-rpcs.ts: drop theGuestbookRpcsimport and makeSiteRpcsjustHealthRpcs.apps/site/src/api/site-rpc-server.ts: drop theGuestbookHandlersimport and itsLayer.provide(GuestbookHandlers)line.apps/site/alchemy.run.ts: drop themigrationsoption fromCloudflare.D1.Database('Database', β¦).pnpm-workspace.yaml: drop the@systemfsoftware/xstateline undercatalog:and its mirror underoverrides:;apps/site/package.json: drop the@systemfsoftware/xstatedependency.- Run
pnpm install --no-frozen-lockfilefrom the shell you already have open. It rewritespnpm-lock.yamlwithout its xstate entries;--frozen-lockfile(pnpm's default whenCIis set) refuses that rewrite. The devshell builds its pnpm store frompnpm-lock.yaml, so it can't start again until the lockfile drops xstate. flake.nix: drop thesystemfsoftware-xstateinput, its name in theoutputsarguments, and its place in theextraDepsOfcopy loop; then runnix flake lock.- Delete this README's guestbook text, from "The site ships one example feature" through the D1 note below.
Removal leaves a deployed D1 as it is: the guestbook_entries table and its 0001_create_guestbook_entries.sql and 0002_add_guestbook_entry_state.sql rows in __alchemy_migrations stay; drop them from the D1 console in the Cloudflare dashboard with DROP TABLE guestbook_entries; DELETE FROM __alchemy_migrations WHERE name IN ('0001_create_guestbook_entries.sql', '0002_add_guestbook_entry_state.sql');.
A copy gets the template's files, not its repository settings, so nothing stops a pull request with failing checks from merging until main has a ruleset that requires them. Install the "gates" ruleset from .github/rulesets/gates.json once, with your own GitHub credentials:
gh api -X POST repos/{owner}/{repo}/rulesets --input .github/rulesets/gates.jsonWhen the checks a ruleset requires change, update the installed ruleset instead of creating a second one: PUT the file to the ruleset's id (gh api repos/{owner}/{repo}/rulesets/<id> -X PUT --input .github/rulesets/gates.json, id from gh api repos/{owner}/{repo}/rulesets), then run pnpm check:branch-rules to confirm the live ruleset and scripts/check-branch-rules.ts agree. A pull request whose checks were renamed hangs on the old contexts until it is applied.
Until it is installed, nothing stops a pull request with failing checks from merging. The ruleset requires the single check job.
Your copy deploys to your own Cloudflare account; the template holds no credentials. Set CLOUDFLARE_API_TOKEN (a token that can edit Workers and D1) and CLOUDFLARE_ACCOUNT_ID, and optionally SITE_DOMAIN (a hostname in a zone on that account) to serve production there instead of on workers.dev:
pnpm run deploy # stage prod
ALCHEMY_STAGE=pr-12 pnpm run deploy # a preview stage
ALCHEMY_STAGE=pr-12 pnpm run destroypnpm run deploy, not pnpm deploy: the bare form is pnpm's own built-in command.
In a copy that has run the section 7 bootstrap and set the repository variable ALCHEMY_DEPLOY to true (plus SITE_DOMAIN as a repository variable to serve production on a domain), every same-repo pull request gets a preview deploy with its URL in a comment and its stage destroyed on close, and main deploys production in one job with no gate in front of it. The template itself never deploys.
The operator runs this once per repository that opts in to deploys. It runs on the host, outside every sandbox (not through bin/cloud or sandbox --), and never on the template. It mints a scoped Cloudflare token and writes it to the repository as secrets, so no one hand-copies a token into repository settings.
The bootstrap requires SITE_DOMAIN: a hostname in a zone on the account, because the CI token's zone grants are scoped to that zone. A copy that serves only on workers.dev skips the bootstrap and creates the CLOUDFLARE_API_TOKEN and CLOUDFLARE_ACCOUNT_ID repository secrets by hand instead.
From apps/site in the dev shell, with the operator's own secrets (supplied from Doppler, key named by the operator; never print them):
cd apps/site
CLOUDFLARE_API_TOKEN=β¦ CLOUDFLARE_ACCOUNT_ID=β¦ GITHUB_ACCESS_TOKEN=β¦ \
GITHUB_REPOSITORY=<owner>/<repo> SITE_DOMAIN=<hostname> \
pnpm exec alchemy deploy --config stacks/github.ts --stage ci-credentialsCLOUDFLARE_API_TOKEN: an admin-scoped Cloudflare token that can create API tokens. A standard Edit Workers token is not enough; use an account API token withAccount > API Tokens > Write, or the Global API Key.CLOUDFLARE_ACCOUNT_ID: the target account.GITHUB_ACCESS_TOKEN: a fine-grained personal access token on that one repository with only Secrets: write, and a short expiry.GITHUB_REPOSITORY:<owner>/<repo>of the repository that receives the secrets.SITE_DOMAIN: a hostname in a zone on the account. The CI token's zone grants are limited to that zone.
These environment credentials win over any alchemy profile, so no --profile flag is needed. The fixed ci-credentials stage means a rerun updates the same token and secrets instead of minting new ones. A rerun changes the token's grants in place and keeps its value.
The stack is named github-<owner>-<repo> from GITHUB_REPOSITORY, so every repository's bootstrap carries its own state: alchemy's state store keys each resource by stack, stage, and resource name (@alchemy.run/cloudflare src/StateStore/Api.ts:75-79, :121-135; the stack name flows from Alchemy.Stack into that key, alchemy/src/Stack.ts:155-157, :216). Two repositories therefore never share a state key, and one repository's destroy cannot delete another's token or secrets.
The stack creates a Cloudflare account API token and two repository secrets, CLOUDFLARE_API_TOKEN and CLOUDFLARE_ACCOUNT_ID. The token holds:
- Account: Workers Scripts Write, D1 Write, Workers Tail Read, and Secrets Store Write. Secrets Store Write is needed because every cloud stage keeps its state in a worker that is bound to the account's Secrets Store.
- The
SITE_DOMAINzone only: Workers Routes Write, DNS Write, and Zone Read. Zone Read is needed because the custom-domain path looks the zone up by name.
After it runs:
- Confirm the secrets exist with
gh secret list(names only). - Revoke the previous hand-made Cloudflare token by name in the Cloudflare dashboard.
- Set the repository variable
ALCHEMY_DEPLOYtotrue, andSITE_DOMAINto the same hostname so production serves on it. Nothing deploys untilALCHEMY_DEPLOYis set. - To rotate the token's value, at least every 90 days, run
pnpm exec alchemy destroy --config stacks/github.ts --stage ci-credentialswith the same environment, then run the deploy command again. The destroy deletes the token, the deploy mints a new one and rewrites both secrets, and deploys fail in between.
A new resource type needs its permission group added to apps/site/stacks/github.ts.
All changes must satisfy local and continuous integration verification gates:
# Format code and markdown
pnpm format:check
# Typecheck workspace packages
pnpm typecheck
# Run linter across packages
pnpm lint
# Run unit and integration tests
pnpm test
# Run every local gate (a superset of what CI runs)
pnpm check:ciWhy does starter require Effect 4 instead of Effect 3?
Effect 4 brings the schema transformations and typed services the template's decisions and boundaries are written with. starter targets the future of Effect rather than supporting legacy patterns.
Why are there no configuration options or preset levels?
Every configuration toggle provides a route for AI agents to downgrade verification standards and reintroduce slop. Zero knobs guarantees that all packages created from this template adhere to identical architectural standards.
How does mutation testing work in this template?
Stryker introduces deliberate syntax and logic mutations into your code and runs your test suite against each mutant. If your tests still pass when code behavior changes, the mutant survives. Mutation is an on-demand script, pnpm --filter @endgame/site mutation (stryker run), never a CI job and never a gate in front of a deploy. The shared config (stryker.shared.ts) mutates only **/*.workflow.ts files in the package, runs the Vitest tests related to each mutant, and breaks the run below a 100% kill score.
How do I migrate an existing codebase to this architecture?
Follow the strangler pattern: pin the published observable behavior of a module, delete the legacy file completely, and rebuild it from a blank page using pure I/O sandwiches. Never patch around a flawed core.
Development setup, workflows, and PR guidelines are documented in CONTRIBUTING.md.
Licensed under the Apache-2.0 License.