M The Microapp Handbook Part II · Chapter 3 of 7

Part II · Chapter 3 of 7

System Design

How the codebase fits together — ubiquitous language, deep modules, sources of truth, decisions made, and the backlog.

01 — The thesis

"Code is not cheap."

Microapp follows Matt Pocock's framing for AI-era software: software fundamentals matter more than ever, not less. Bad code makes AI worse — it amplifies the chaos. Good code lets AI do its best work.

A bad codebase is one you can't change without causing bugs (Ousterhout). A good codebase resists entropy — every commit either keeps it tidy or makes it tidier (Pragmatic Programmer / Kent Beck).

Code entropy: clean desk on Day 1, chaotic desk on Day 90

The bargain

Specs-to-code divests from design. We invest in design every day — through ubiquitous language, deep modules, build-time invariants, and locked decisions.

02 — Stack

Static-first, edge-served, type-safe.

  • Astro 5 SSG — every page prerender = true, output "static". No Worker invocation per request.
  • React 19 islands — only the interactive widgets hydrate. Tool widgets client:load; AuthModal / LanguageSwitcher client:idle.
  • Cloudflare Pages — CDN-edge serving from dist/.
  • Supabase — tool metadata + i18n translations + ratings. Build-time read only; the browser never queries directly.
  • Tailwind 4 via @tailwindcss/vite. Brand tokens live in @theme.
  • TypeScript strict. astro check runs voluntarily today; CI gate is on the backlog.

03 — Ubiquitous language

Same word — same meaning.

Pocock's #2 failure mode: AI talks past you because you don't share a vocabulary. We use these terms consistently across code, conversations, and this document. If a term is missing here and you need it, add it before using it in code.

Three diverse professionals pointing at the same word card — one word, one meaning
TermDefinition
slugKebab-case canonical identifier, no leading slash. word-counter, bmi-calculator. The English slug is canonical for a tool across all locales.
localized slugTranslated slug used in non-English URLs. contador-palabras for es. Lives in src/i18n/slug-mappings.ts (auto-generated).
toolOne entry in src/lib/tools-data.ts — slug, label, desc, category, keywords, optional featured.
widgetThe interactive React component for a tool. Lives in src/components/tools/<PascalName>.tsx. ToolIsland auto-discovers via Vite glob.
engine configConfig in src/lib/tool-registry.ts for Tier 1 converters/calculators that don't need a custom widget — rendered by <ToolEngine>.
categoryOne of 7 fixed categories. Canonical record in src/lib/categories.ts; URL is /categories/<category-slug>.
featuredBoolean flag on a tool. true = appears on the home page hero grid (~18 tools). The rest reach via /categories and search.
localeOne of 7 codes (en, ru, es, hi, ar, de, pl). en is default and uses no prefix; others use /<locale>/...
translationA row in Supabase tool_metadata_translations keyed by (slug, locale). NOT the same as i18n UI strings.
i18n stringsUI text translations bundled into the JS. Source: src/i18n/locales/<lang>/{common,home,tool,ratings}.ts.
deep moduleA component or function with a small interface and complex internals hidden inside (Ousterhout). We actively prefer these.

04 — Architecture

Build at home. Serve at the edge.

Tool metadata lives in Supabase. Astro's getStaticPaths() reads it at build time and emits one static HTML file per (tool × translated locale). Cloudflare Pages serves those files from its CDN — no Worker, no per-request compute, no per-request cost.

Architecture flow: Supabase to Astro Build to Cloudflare CDN

Each request hits a static file. Inside that file, the only JavaScript that runs is the tool's interactive widget (a React island) — and that hydrates only after first paint.

What's NOT in the architecture

No Express server. No tRPC. No Node runtime in production. The Astro adapter is gone (commit 2b167d1) — switching back means installing @astrojs/cloudflare and changing one config line, but only if a server route actually needs to exist.

05 — Deep modules

Small interface. Complex internals. Hidden.

Ousterhout: "Modules should be deep." A simple, narrow interface with rich behavior behind it. The opposite — many shallow modules — forces every reader (you, AI, future-self) to navigate the whole graph just to understand any part.

Deep module: simple interface outside, complex internals hidden inside

Our deep modules

  • getAllToolTranslations()Interface: () => Promise<Record<slug, Record<locale, ...>>>. Internals: one Supabase query, memoized at module scope, error-tolerant. Used by 3 routes; nobody reaches in.
  • <ToolIsland slug={…} />Interface: one prop. Internals: Vite glob auto-discovery, slug aliasing for legacy URLs, Tier 1 engine dispatch. Adding a tool = drop a file in src/components/tools/.
  • <ToolLayout tool ... />SEO chrome + slots (tool-widget / faq-accordion / ratings). Caller passes data; layout emits HTML + locale-aware JSON-LD.
  • <HomeIsland lang translations />Renders the curated home page. Pure render — no fetching, no client state. The 18 featured tools come from tools-data.ts, the visuals from TOOL_VISUALS.
  • Brand design system (@theme tokens)Interface: bg-brand-green, font-display, etc. Internals: hex values + font stacks, defined once in src/styles/global.css. Change a brand color in one place; ~70+ consumers update.

06 — Sources of truth

Each piece of data lives in exactly one place.

The hardest design problem on this codebase has been: where does this piece of information live? We've answered it for "tool"; "category" is partly fragmented and on the backlog.

File / tableOwnsWhy separate
src/lib/tools-data.tsCanonical list of tools (slug, label, desc, category, featured)Single TS source; bundle-friendly
src/lib/categories.tsThe 7 fixed categoriesImported by category hub pages and ToolLayout nav
src/lib/tool-registry.tsEngine configs (Tier 1)Engine logic = JS functions; can't live in a DB
src/components/tools/*.tsxTool widgets (Tier 2/3)One file per tool. ToolIsland glob auto-discovers
TOOL_VISUALS in HomeIslandPer-featured-tool icon + colorsVisual-only concern; build-time invariant catches drift
Supabase tool_metadataEnglish SEO copy (title, intro, FAQs, etc.)Long-form content + ratings live in DB
Supabase tool_metadata_translationsPer-locale translated SEO copyTranslations would bloat the bundle
src/i18n/locales/<lang>/UI string translationsBundled — needed at render time
src/i18n/slug-mappings.tsEnglish → localized slug per localeAuto-generated; don't hand-edit

Hard rule

If you find yourself adding a fourth source for "tool" — stop and discuss. The rule is one source per concept; deviations need a reason that survives a follow-up review.

07 — How to add a tool

Today: 7 places. Goal: 1.

The current recipe. Backlog item #4 is collapsing this into a pnpm new-tool <slug> scaffold script.

1. Create the widget                  src/components/tools/MyToolName.tsx
2. Add to canonical list              src/lib/tools-data.ts (one ToolEntry)
3. (Optional) flag for home grid      featured: true   in tools-data
4. (If featured) add visual entry     TOOL_VISUALS["/my-tool"] in HomeIsland.tsx
5. Add SEO copy in Supabase           INSERT into tool_metadata
6. Add per-locale translations        INSERT into tool_metadata_translations
7. Re-run slug-mappings generator     scripts/generate-slug-mappings.mjs
  • Skip step 5 → page 404s (no getStaticPaths row).
  • Skip step 4 with featured: true → build fails with the TOOL_VISUALS invariant.

08 — Decisions made

Don't re-derive these.

Each of these is a closed question. Reopen only with a specific reason and the room to commit either way.

  • Why output: "static"?

    No route uses server runtime. Cloudflare Worker invocations cost latency and money for nothing. Reinstall @astrojs/cloudflare and switch to "server" if a real server route is added later.

  • Why translated slugs (/es/contador-palabras, not /es/word-counter)?

    Strong organic SEO in non-English markets. Yandex prefers Cyrillic Russian slugs even with the URL-encoding ugliness — the ranking signal beats the friction.

  • Why featured-only home page (~18 tools, not all 115)?

    Concentrates internal-link equity, gives home a clear editorial intent, removes a 130-line drift surface. Long-tail discovery moves to /categories/<slug> and the search palette.

  • Why tools-data.ts as canonical, not Supabase?

    Typed, version-controlled, no build-time DB dependency for the basic listing. Supabase owns long-form SEO copy and translations only.

  • Why drop locale × tool pages with no translation?

    Duplicate-content risk. Untranslated users land on the canonical English URL; the locale URL simply doesn't exist.

  • Why batch translation fetch (getAllToolTranslations)?

    Was 576 sequential awaits in getStaticPaths. Now one query memoized at module scope. Build time dropped from minutes to seconds.

  • Why @theme tokens, not Tailwind config?

    Tailwind 4 wires CSS variables and utility classes from one @theme block. Inline-style sites can use var(--color-brand-green); Tailwind sites use bg-brand-green. Single source.

09 — The agent team

Three skills. One runner. Any frontend.

Microapp ships with three named agents that handle the build pipeline. Each is a SKILL.md file (plus templates and checklists) under .claude/skills/. The runner — a thin TypeScript loop using the Vercel AI SDK — turns those skills into something callable from any frontend: Claude Code, Slack, GitHub Actions, Manus, your laptop CLI.

Research

Steph

Find what to build next. Ahrefs + GSC + competitor diff, scored by volume × difficulty × intent × buildability × novelty. Flags which picks need Kai.

Inputs: tools-data.ts, GSC, Ahrefs

Product

Kai

Spec for non-trivial tools — required features, edge cases, acceptance criteria. Decides per-locale whether Simon should translate. Refuses to over-spec trivial tools.

Inputs: Steph's slug + Ahrefs SERP + competitor pages

Build

Bob

Ship a tool against Kai's spec (when present). Generates widget, wires catalog, auto-runs Supabase seed, runs gate, opens PR. Refuses out-of-scope.

Inputs: Spec at data/specs/<slug>.md, or just slug

Writer

Lace

Long-form English SEO article (1500–2500 words). Picks template by archetype, applies brand voice, validates against checklist, verifies examples against the actual tool.

Inputs: Tool metadata + Bob's widget source

Translate

Simon

Translate Lace's article + metadata into the locales Kai marked `yes`. Adapts keywords per locale (not literal). Writes Supabase rows + regenerates slug-mappings.

Inputs: Lace's English + Kai's translation block

QA

Ben

Test Bob's PR against everything: adversarial inputs, live preview, a11y audit, brand consistency, cross-locale smoke (each Simon-translated URL).

Inputs: PR diff + preview URL + spec

The pipeline runs sequentially with humans-in-the-loop at the boundaries: Steph reports → human picks one → (if non-trivial: Kai writes spec → human reviews) → Bob builds + auto-seeds Supabase → CI runs (vitest + astro check) → Cloudflare builds preview → Lace drafts the English article → (if Kai flagged any locales `yes`: Simon translates) → Ben tests preview against the spec, including each translated URL → human reviews and merges. The chain stays human-supervised on purpose until trust accumulates.

The portable runner — why we don't lock to Claude Code

Skills are data; the runtime is the consumer. Each skill is plain Markdown — model-agnostic, runtime-agnostic. The runner at agents/lib/runner.ts loads a SKILL.md, hands it to whichever model is configured (Claude Opus 4.7 by default, swappable to GPT-5 / Gemini / local Llama with one config line), and runs the tool-use loop.

Same engine, many frontends:

agents/
├── lib/
│   ├── runner.ts       # the agent loop (Vercel AI SDK)
│   ├── tools.ts        # bash, read, write, edit, fetch
│   └── providers.ts    # default Claude; swappable
└── cli.ts              # pnpm agent <name> <prompt>

.claude/skills/
├── bob/SKILL.md        ← same skills, every runtime
├── ben/SKILL.md
├── steph/SKILL.md
└── seo-article/SKILL.md

Frontends — all call the same runner:
  • Claude Code        /bob slug-here
  • CLI (any host)     pnpm agent bob slug-here
  • GitHub Actions     node agents/cli.ts bob ${{ inputs.slug }}
  • Slack bot          on /bob → invoke runner → post result back
  • Manus              imports the same SKILL.md files
  • Cron (weekly)      pnpm agent steph "weekly report"

Editing a skill in this repo propagates to every frontend the next time the runner is invoked. No upload, no sync, no drift. Adding a new agent is one new .claude/skills/<name>/SKILL.md; the CLI auto-discovers it.

Refusal is a feature

Every agent has a refusal section in its SKILL.md. Bob refuses tools needing server state or Covenant violations. Ben refuses to declare PASS when a blocker is open. Steph refuses to recommend tools we can't build. The agent team isn't a yes-machine — bounded scope is what makes their output trustworthy enough to ship.

Foundation behind the agents (already in place):

  • tests/tools.test.ts — 27 vitest cases catch slug drift, transformer regressions, catalog integrity. Runs in CI on every PR.
  • pnpm exec astro check — full TypeScript across 250+ files. Runs in CI. Currently 0 errors.
  • scripts/gsc-pull.mjs + scripts/steph-research.mjs — data pipelines that the agents consume.
  • SLUG_ALIASES map + TOOL_VISUALS invariant — guard rails that fail loud.

Open work for full autonomy: wire GitHub Actions triggers (weekly Steph cron, comment-triggered Bob, on-PR Ben), wire a Slack bridge, and pick an analytics provider so the agents can measure their own impact. None of that requires touching the agents themselves — only adding new frontends to the same engine.

10 — Open backlog

Deliberate non-decisions.

Items here are deferred on purpose. Each has a reason. Don't re-propose without reading; do propose if the priority should change.

  1. "Category" ubiquitous-language fix. categoryHref + categoryLabel + category (id) + CategoryMeta.slug + .anchor are 5 fields meaning related-but-different things. Same problem we fixed for 'tool' — do it for category.
  2. Wire agent frontends. GitHub Actions for cron-Steph + comment-Bob + on-PR-Ben, plus a Slack bot bridge. Agents don't change; only triggers do.
  3. Migrate the 114 tool widgets to brand tokens. Each widget has 0–7 brand-hex literals. Independent, low-risk. Mop up in one sweep when visual consistency matters.
  4. pnpm new-tool <slug> scaffold script. Collapse the 7-place tool-add into one command.
  5. Tier 1 engine i18n. <ToolEngine> renders English unit labels everywhere. Defer until non-English traffic shows demand.
  6. RTL verification for Arabic. dir='rtl' is plumbed; visual flip needs a browser audit.
  7. Per-tool dynamic OG images. Single static og-image.png today; build-time generate per-tool with satori.
  8. Service worker. Instant tool-to-tool nav (@vite-pwa/astro).
  9. Replace main with astro-migration. Once we're confident in this rewrite. Old SPA is feature-frozen.

11 — Pocock score

Honest current state vs the framework.

Where the codebase sits today against Pocock's six failure modes. Aspirational, not promotional.

DimensionScoreNote
Render pipeline (page → layout → island)8/10Each layer hides complexity from the next; deep.
Tool widget pattern (glob auto-discovery)9/10Drop a file, it works. Textbook deep module.
Tool data layer7/10tools-data.ts is canonical now; 7-place tool-add is the gap.
Design system (brand tokens)7/10Foundation in place; 114 tool widgets still inline.
Layouts (ToolLayout / HomeNav / etc.)6/10Inline styles gone; could split into smaller modules.
Category data layer4/105 fields meaning related things — backlog.
Translation system5/10Two parallel implementations (i18n strings vs Supabase translations).
Build feedback / invariants9/10vitest + astro check both gating in CI; TOOL_VISUALS + slug-roundtrip + transformer regressions all asserted.
Documentation / shared design concept8/10AGENTS.md + CLAUDE.md + this page.
Agent infrastructure7/10Steph/Bob/Ben skills + portable runner; frontends (Slack, GitHub Actions cron) still manual.
Weighted overall~7/10Up from ~3/10 at the start of the design-investment work.

The trajectory matters more than the number

The talk's actual lesson is invest every day. ~30 design-level commits this week. Next 30 should keep going down the backlog — not back to feature work.