The harness-first, AI-first SaaS starter. Cloudflare Workers + React Router v7 + tRPC + D1 + Drizzle + Better Auth + Effect TS + ShadCN. Same runtime on your laptop and at the edge. Drop in any agent (Claude Code, Cursor, Codex) and ship real features with specs and tickets in GitHub issues, planning skills, and deterministic verification gates.
Three pillars:
- Harness-first — agents stay coherent across sessions via the 5-subsystem framework: instructions, state, verification, scope, lifecycle.
- AI-first — six project-local sub-agents, Matt Pocock's planning skills, seven gates, five AST-checked non-negotiables.
- DX that just works —
bun install+bun run devand you're live.bun run scripts/first-time-setup.tsgets you from zero to deployed Worker in ~3 minutes. Local dev runs the same Workers runtime that ships to production.
📦 Latest release: v1.2.0 — The Boilerplate Audited Itself
If you're a human, scroll to Quick Start. If you're an agent, scroll to How To Work In This Repo — it tells you how to read the project state before writing code.
What's in the box
Stack
- Runtime: Cloudflare Workers (no Node), React Router v7 SSR
- Server logic: tRPC v11 procedures wrapped in Effect TS
- Persistence: D1 (SQLite) via Drizzle ORM 0.45, R2 for files
- Auth: Better Auth 1.4 with Drizzle adapter + admin plugin (RBAC)
- Validation: Effect Schema everywhere — no Zod
- Errors:
Data.TaggedErrormapped to tRPC codes viatagToTRPC - UI: ShadCN/Radix + Tailwind v4 (oklch), next-themes, react-hook-form + Effect resolver
- i18n: remix-i18next + i18next, route-level namespaces, fully typed
- Testing: Vitest 3 (unit) + Playwright 1.58 (e2e) +
@effect/vitest - Background: Cloudflare Workflows (
ExampleWorkflow)
Agent harness
- GitHub issues and PRs — specs, tickets and verdicts; the project state
scripts/verify.sh— runs the gates; CI runs the same ones.githooks/pre-commit— typecheck + tests block broken commits (no-dep, auto-installs)CLAUDE.md→AGENTS.md— one file (symlink), the agent entry point
In the wild
Real apps built on this starter — same runtime, same harness, same conventions:
| Project | What it is |
|---|---|
| home-karaoke 🎤 | Open-source group karaoke — host a room on the big screen, guests scan a QR to search YouTube and add songs to a live shared queue. Extends the base with Cloudflare Durable Objects for the real-time queue. |
| portfolio-v3 | Sean's personal portfolio — the starter stripped down to a content-first site on the edge. |
Shipped something on it? Open a PR adding it here.
Quick Start
Prerequisites
# Bun (package manager + runtime)
curl -fsSL https://bun.sh/install | bash
# Cloudflare CLI
bun add -g wrangler
wrangler login
Option A — Automated setup (recommended)
bun run scripts/first-time-setup.ts
The wizard creates your D1 database, R2 bucket, optional KV namespace, generates a BETTER_AUTH_SECRET, writes wrangler.jsonc + .env, runs migrations, and deploys. ~3 min end-to-end.
Option B — Manual
bun install # also runs cf-typegen + git hooks install
bun run db:migrate:local # apply migrations to local D1
bun run db:seed # seed local D1 with admin/user/banned fixtures
bun run dev # http://localhost:5173
How To Work In This Repo
For humans
Read CLAUDE.md once. It points to everything else.
For agents
You're working in a codebase with strict conventions. Skipping the plan will cost rework. Project state lives in GitHub issues and PRs, plus AGENTS.md, GLOSSARY.md and docs/adr/. The steps:
- Start. Read the issue (
gh issue view), the docs above, and the open PRs. Run./scripts/verify.sh --baselineso you know the tree is green before you touch it. - Plan when the task carries a decision.
/grill-meinterviews you until the design is clear;/grill-with-docsdoes the same and also writesGLOSSARY.mdand ADRs./to-specwrites the spec as a GitHub issue./to-ticketscuts it into vertical-slice issues with blocking links. - Work. One ticket at a time. Frontend work loads the design skill for its lane (see Design skills below).
- Verify. The
feature-verifiersub-agent walks the flow in a browser, saves screenshots to.verify/<slug>/, and writes a PASS / FAIL / BLOCKED verdict you paste into the PR. - Done.
./scripts/verify.shis green and the verdict is PASS. Merge the PR that closes the issue.
The five non-negotiables (enforced by the non-negotiables gate, scripts/check-non-negotiables.ts):
- Effect TS by default. No
throw. Notry/catchoutsideEffect.tryPromise. - Effect Schema for validation. No Zod.
- Tagged errors only. All in
app/models/errors/. Map every one inapp/lib/effect-trpc.ts. - Unit test every helper, repository, and service.
- Cloudflare Workers, not Node. Bindings via the
CloudflareEnvTag. Neverprocess.env.
Harness — the system around the agent
This repo follows the walkinglabs 5-subsystem harness framework. The harness is everything that keeps coding agents reliable across sessions: instructions, state, verification, scope, and lifecycle.
State lives in GitHub: the spec is an issue, each ticket is an issue, and a merged PR ships it. Nothing sits in a local database, so a fresh clone sees the same plan. Lasting rules go in AGENTS.md, GLOSSARY.md or an ADR in docs/adr/. Planning uses Matt Pocock's skills (installed from skills-lock.json); no extra setup beyond gh signed in.
| Subsystem | Artifacts |
|---|---|
| Instructions | CLAUDE.md / AGENTS.md, Matt Pocock's skills, GLOSSARY.md, docs/adr/ |
| State | GitHub issues (spec and tickets) and PRs (verdicts); gh issue list, gh pr list |
| Verification | Gates run by scripts/verify.sh; scripts/harness-check.sh; feature-verifier browser walks |
| Scope | One ticket at a time; ≤2 features per diff |
| Lifecycle | init.sh, .claude/hooks/session-start.sh (skill restore + pointers) |
Gates
| Gate | Command | Runs in |
|---|---|---|
| typecheck | bun run typecheck |
baseline, full |
| test | bun run test |
baseline, full |
| non-negotiables-selftest | bun run scripts/check-non-negotiables.ts --selftest |
baseline, full |
| non-negotiables | bun run scripts/check-non-negotiables.ts |
baseline, full |
| harness | ./scripts/harness-check.sh |
baseline, full |
| build | bun run build |
full |
| e2e | bun run test:e2e |
full |
./scripts/verify.sh --baseline runs the quick set; bare ./scripts/verify.sh runs them all. CI runs the same script, so adding a gate is a one-line change there.
Slash commands
| Command | What it does |
|---|---|
/harness-check |
Repo invariants (CLAUDE↔AGENTS symlink, sub-agent frontmatter, hook wiring) via scripts/harness-check.sh — no LLM, exits non-zero on drift. |
Project-local sub-agents (.claude/agents/)
| Agent | Use when |
|---|---|
effect-ts-enforcer |
After writing — review the diff against the 5 non-negotiables |
test-author |
After a change — write the tests that pin it |
span-instrumenter |
After adding a procedure, repo method or service — add tracing spans |
ui-builder |
Any user-visible work |
context-memory |
Keep per-feature working memory — context, decisions, links, todos — shared by every worktree |
feature-verifier |
Walk a user-visible flow in the browser and record the verdict |
See .claude/agents/README.md for invocation flow + complementary plugin agents.
Guardrails
These run automatically — you do not need to remember them.
Pre-commit gate (.githooks/pre-commit)
Triggered on git commit. Skips entirely if no .ts/.tsx/.js/.jsx files are staged. Otherwise:
bun run typecheck # cf-typegen + react-router typegen + tsc -b
bun run test # vitest run (123+ unit tests)
Auto-installed via postinstall (sets core.hooksPath = .githooks). Re-install manually with bun run hooks:install. Bypass for an emergency commit:
SKIP_HOOKS=1 git commit -m "..."
CLAUDE.md ↔ AGENTS.md
CLAUDE.md is a symlink to AGENTS.md, so both names resolve to one file. Edit AGENTS.md; scripts/harness-check.sh fails if the two ever differ.
Project layout
app/
├── auth/ Better Auth server config + client
├── components/ UI — shadcn primitives in ui/, feature components alongside
├── db/ Drizzle schema + connection
├── hooks/ React hooks
├── i18n/ SSR + client i18next setup, types
├── lib/ Helpers — schemas/, logger, effect-trpc, effect-utils
├── locales/en/ Translation JSON files (one per namespace)
├── models/errors/ Tagged error classes
├── repositories/ Drizzle-backed Effect.Service repositories
├── routes/ React Router v7 file-based routes
├── services/ Effect Tag/Layer services (Database, Bucket, AuthApi, etc.)
├── trpc/ tRPC router + procedures + middleware
├── routes.ts Route config
├── runtime.ts ManagedRuntime composition (services + repos)
├── root.tsx Root layout
└── entry.{client,server}.tsx
.githooks/ Git hooks (pre-commit gate)
.claude/ Claude Code config — settings, hooks, agents/, commands/
drizzle/ SQL migrations
e2e/ Playwright tests
public/ Static assets
scripts/ Setup / teardown / seed + verify.sh + harness-check.sh
workers/app.ts Cloudflare Workers entrypoint
workflows/ Cloudflare Workflow definitions
init.sh Harness bootstrap — install + skills + migrate + typecheck + test
Commands
./init.sh # Harness bootstrap — install + skills + migrate + typecheck + test
./init.sh --baseline # Baseline only (typecheck + test + harness-check)
./init.sh --quick # Skip install + migrate (assume already done)
./scripts/harness-check.sh # repo invariants (also: /harness-check)
bun run dev # Dev server (auto-runs local DB migrations) → :5173
bun run build # Production build
bun run deploy # Build + deploy to Cloudflare Workers
bun run preview # Build + serve locally via the Cloudflare Vite plugin (vite preview)
bun run typecheck # cf-typegen + react-router typegen + tsc -b
bun run test # Vitest (one-shot)
bun run test:watch # Vitest watch
bun run test:e2e # Playwright
bun run test:e2e:ui # Playwright UI mode
bun run test:e2e:videos --out <dir> # Record the @golden/@error specs as PR videos
bun run test:e2e:report # Open last Playwright report
bun run db:generate # Generate Drizzle migration from schema
bun run db:migrate:local # Apply migrations to local D1
bun run db:migrate:remote # Apply migrations to remote D1
bun run db:seed # Seed local D1 with admin/user/banned fixtures
bun run db:seed:preview # Seed the preview-env D1 with the same fixtures
bun run db:studio # Drizzle Studio (visual DB editor)
bun run cf-typegen # Regenerate worker-configuration.d.ts
bun run hooks:install # Re-install pre-commit gate
bun run setup # First-time wizard
bun run teardown # Tear down Cloudflare resources
Authentication
Better Auth 1.4 with the Drizzle adapter and admin() plugin for RBAC.
- Auth handler:
/api/auth/*(app/routes/api/auth.$.ts) - Server config:
app/auth/server.ts - Client:
app/auth/client.ts(usesadminClient()plugin) - Session reading in loaders:
context.auth.api.getSession({ headers: request.headers }) - Session reading in Effects: yield the
SessionTag fromapp/services/session.ts
RBAC — admin role enforcement is two-layered:
- Page-level — admin route loaders redirect non-admins (see
app/routes/admin/_layout.tsx) - Procedure-level —
adminProceduremiddleware inapp/trpc/index.tsrejects non-admins server-side
Never trust the page guard alone — UI hiding doesn't equal authorization.
Set the production secret:
openssl rand -base64 32
wrangler secret put BETTER_AUTH_SECRET
Promote a user to admin — Drizzle Studio:
bun run db:studio
# Open user table → edit `role` cell → change "user" → "admin" → save
Or remote:
bunx wrangler d1 execute YOUR_DB_NAME --remote \
--command "UPDATE user SET role = 'admin' WHERE email = 'user@example.com'"
Or seed the fixture accounts locally:
bun run db:seed
Gives you admin@preview.local / Password123! (plus user@preview.local
and banned@preview.local). See Database below. Every feature
that adds a table or user-visible data extends these fixtures in the same diff.
Database
D1 (SQLite) accessed through Drizzle. Schema lives in app/db/schema.ts — single file at current scale.
Workflow for schema changes:
# 1. Edit app/db/schema.ts
# 2. Generate migration
bun run db:generate
# 3. Review SQL in drizzle/<NNNN>_<name>.sql
# 4. Apply locally
bun run db:migrate:local
# 5. After PR merge, apply to prod
bun run db:migrate:remote
Use SQL defaults for timestamps (
unixepoch('subsecond') * 1000), not JS-sidenew Date().
UI / Design system
ShadCN primitives in app/components/ui/. Compose into feature components elsewhere. Tailwind v4 with oklch CSS variables in app/app.css.
Add a shadcn component:
bunx shadcn@latest add <component-name>
Design skills
Agents load one of these on every frontend task. None is committed; each comes from its upstream repo:
- Plugins, enabled in
.claude/settings.json:impeccableandtaste-skill. Claude Code asks to install them when you trust the repo. - Plain skills, listed in
skills-lock.jsonbecause their repo ships no plugin: the three motion skills.scripts/restore-skills.shinstalls any that are missing into.agents/skills/and links them into.claude/skills/— run by./init.sh, and in the background by the Claude Code SessionStart hook. Both directories are gitignored.
| Skill | Source | Job here |
|---|---|---|
impeccable (plugin) |
pbakaus/impeccable | Leads app screens — dashboard, admin, settings, forms. Critiques app screens from screenshots (critique, P0/P1 block) and runs the finish pass (audit, harden, polish) on every surface. |
design-taste-frontend (plugin taste-skill) |
Leonxlnx/taste-skill | Leads public pages — landing, marketing, pricing. Art direction plus a screenshot look-and-fix loop that scores the render and gates public pages. |
emil-design-eng · animate · review-animations (lock) |
emilkowalski/skills | Own all motion — easing, duration, enter/exit, gestures. review-animations runs only when you call /review-animations. |
One skill leads each surface, so they never fight over the same choice. The tokens in app/app.css beat all of them. Lane rules: AGENTS.md.
The restore pulls each skill's latest upstream version, unpinned.
To add a design skill: if its repo ships .claude-plugin/marketplace.json, add the marketplace to extraKnownMarketplaces and the plugin to enabledPlugins in .claude/settings.json. Otherwise:
npx skills add <owner/repo> --skill <name> -y # adds it to skills-lock.json
./scripts/restore-skills.sh # installs and links it; commit the lock only
Forms — always Effect Schema + effectResolver (no Zod resolver):
import { effectResolver } from "@hookform/resolvers/effect-ts";
import { LoginSchema, type LoginInput } from "@/lib/schemas/auth";
const form = useForm<LoginInput>({
resolver: effectResolver(Login