9.1 — App Builder architecture

9.1 — App Builder architecture

Pro+ feature. The App Builder is a premium add-on gated behind the buildApp feature flag, which is true on Pro, Team, and Enterprise (and for admins) and false on Free and Starter. This page is for contributors working on the feature; the user-facing guide is User docs: 12 App Builder.

The App Builder compiles a VibeMap project's atomic blueprint into a complete, deployable Next.js + Supabase app. Where VibeMap's other pipelines stop at a structured spec, this one emits source code, verifies it, and ships it.

The core reframe: this is not a from-scratch "Bolt clone." VibeMap already owns the hardest thing prompt-first builders lack — a validated, normalized spec (entities, access rules, interactions, acceptance criteria). The App Builder brings the coding agent in-house and gives it a place to run.

The build pipeline

A single Inngest orchestrator runs the whole thing. The shape mirrors the existing prepare-for-development.ts pattern: an event triggers a multi-step function that writes status back as it goes.

build/app.requested ──▶ inngest/functions/build-app.ts
  │
  1. PREFLIGHT   blueprint ready + entitlement + (at GA) OAuth connected
  2. COMPILE     deterministic compiler: blueprint -> file tree + GAPS         (no LLM)
  3. FILL        one scoped agent per gap, write-clamped to its own files      (LLM)
  4. VERIFY      install · type-check · lint · build -> structured errors
  5. REPAIR      red? per-file repair agents -> re-verify (≤ 3 rounds)         (LLM)
  6. DEPLOY      push to GitHub repo (Octokit) · deploy on Vercel
  7. REPORT      live URL · repo · gap/fill counts · error summary -> build_runs

runBuild() in lib/builder/orchestrate.ts owns stages 2–5: it calls compileBlueprint, then fillGaps, then runVerifyRepair, and returns a BuildOutcome (ok, stage, files, gap/fill counts, ownership violations, remaining errors). It never throws on a build failure — it returns ok: false with diagnostics. Deploy (stage 6) lives in the Inngest function so the pure pipeline stays side-effect-free.

Why this is the differentiator

The blueprint is used three ways in one run:

  • Input — the compiler walks it deterministically (stage 2).
  • Goal — each gap's acceptanceCriteriaIds become the agent's objective (stage 3).
  • Test oracle — verification gates the output against the same spec (stage 4).

access_rules + op_conditions become RLS policies and route guards deterministically; acceptance_criteria become both the agent goals and the verify gate. That triangle is what a prompt-only competitor can't replicate.

The emitter model

The compiler is a pure functioncompileBlueprint(blueprint) → { files, gaps, unsupported }, no I/O, no Date.now() / Math.random(). Same blueprint in, same tree out (file keys sorted, gaps sorted by id) so output is reproducible and fully unit-testable. See lib/builder/compile.ts.

It runs an ordered registry of emitters, each consuming one slice of the blueprint and contributing files (later emitters overlay earlier ones). The registry lives in lib/builder/emitters/index.ts:

EmitterBlueprint sliceEmits
scaffoldEmitterprojectpackage.json, config, Tailwind, app/layout, globals.css (pinned versions)
schemaEmittertables + relationshipsper-table migration (columns, FKs, indexes), typed row interface, CRUD server actions
accessEmitteraccess rules + page access rulesRLS policies (all / own / own_via / custom) and middleware route guards
authEmitterwhich pages are auth-required@supabase/ssr module: login/signup/callback routes, session middleware, protected-route wrapper
paymentsEmitterpayments flagStripe module — subscriptions + one-off payments + webhook handler (only when payments are enabled)
pagesEmitterpages / sections / navigationroute files + layout shells; pure-CRUD pages fully deterministic, others emit a shell + a Gap

The registry has since grown with config-driven emittersfileStorageEmitter, contentEmitter, teamEmitter, and marketingPagesEmitter — and several of the emitters above now branch on the project's architecture config: authEmitter trims to the chosen sign-in method (+ MFA), paymentsEmitter to the chosen payment model, and accessEmitter to the roles model. Each gates on a config accessor and emits nothing when its slice is off. See 9.2 Architecture configuration for the full config→output mapping.

Cross-emitter invariant — authentication "none": pagesEmitter and authEmitter must agree on the auth mode. Pages call requireUser() unconditionally, so for authentication: "none" the auth emitter emits a no-op lib/auth/guards.ts (there is no /login route to redirect to) and the pages emitter omits the sign-out form (there is no app/(auth)/actions.ts to import). This is covered by tests/unit/builder/import-integrity.test.ts — a 2026-07 regression where the two emitters disagreed made the deterministic scaffold fail tsc before any AI stage ran.

The emitter contract

An emitter implements the Emitter interface from lib/builder/types.ts:

interface Emitter {
  name: string;
  emit(ctx: EmitterContext): EmitterResult;
}

EmitterContext is read-only ({ blueprint, projectName }). EmitterResult carries the files it produced, plus optional gaps, unsupported, npm dependencies / devDependencies, and env vars. The compiler merges declared dependencies into the generated package.json and assembles declared env vars into a generated .env.example — so an emitter never edits those files directly; it just declares what its code needs.

Adding a new emitter or template

  • New emitter → add a file under lib/builder/emitters/, implement Emitter, and register it in emitters/index.ts (order matters — later emitters overlay earlier ones).
  • New template → add it under lib/builder/templates/ (organized by domain: scaffold, schema, access, auth, pages, payments). Templates are versioned, same convention as lib/prompts/, and every emitted file carries a provenance comment so repair/rebuild can reason about its origin.
  • Keep emitters pure. Anything non-deterministic belongs in the gap-fill or deploy stages, not the compiler.

Gaps, fill, and verify

A Gap is a slot the compiler can't fully resolve — a non-CRUD page body, an interaction handler, or an acceptance criterion with no covering file:

interface Gap {
  id: string;
  kind: "page_body" | "interaction_handler" | "uncovered_ac";
  targetFiles: string[];            // the ownership manifest — the only files this gap may write
  acceptanceCriteriaIds: string[];  // the agent's goal
  context: string;
}
  • Fill (lib/builder/gaps/) dispatches one scoped agent per gap, in parallel and capped. Each agent gets the frozen skeleton as context and is write-clamped to its targetFiles via the ownership manifest; writes outside the manifest are reported as ownershipViolations, not silently applied.
  • Verify + repair (lib/builder/verify/) runs the generated tree through install / type-check / lint / build, parses the output into structured errors, and routes per-file repair agents. Repair is hard-capped at 3 rounds (maxRepairRounds), then the build is marked failed with the remaining errors.

LLM calls reuse the shared generation infrastructure (createGapLLM); CI runs through an injected VerifyDriver (createNodeDriver), so the pipeline is testable with fakes.

Deploy

Provisioning + deploy adapters live in lib/builder/deploy/ (github.ts / github-client.ts via Octokit, vercel.ts, env.ts). The orchestrator pushes the verified tree to a new private GitHub repo, then triggers a Vercel deploy linked to it; secrets are stamped into the Vercel project env and never committed (the repo ships .env.example only). A Vercel failure leaves the repo intact (deployUrl stays null) rather than losing the build.

Current state of the last mile. Per-user token provisioning is live: deploy resolves each user's OAuth token from app_integrations → Supabase Vault via getUserProviderToken (lib/builder/integrations/tokens.ts); tokens are captured by the OAuth callback at app/api/integrations/[provider]/callback. For GitHub there is no silent fallback to VibeMap's shared GITHUB_PAT (that would push the user's app into VibeMap's account) — the shared PAT is only used behind the internal BUILDER_ALLOW_SHARED_GITHUB_PAT flag; without a token the build runs in build-only mode (no deploy, graceful skip). Vercel falls back to process.env.VERCEL_TOKEN. Repo reuse across rebuilds is handled by a stable per-project repo name plus an idempotent createRepoWithFiles, and a ZIP-artifact producer uploads to the app-builder-artifacts bucket. Still TODO: per-user Supabase connect (deploys currently stamp placeholder Supabase env vars the user replaces in Vercel) and an artifact retention/cleanup policy. The Inngest function runs with retries: 0 — by design, a user must manually retry from the UI (cost control).

Data model

TablePurpose
app_integrationsPer-user provider connection state (provider, connected) for Supabase / GitHub / Vercel / Stripe. Holds a vault_secret_id pointer per connection — raw OAuth tokens live in Supabase Vault, never in the table.
build_runsOne row per build: project_id, status, stage, gap_count, filled_count, cost_cents, repo_url, deploy_url, zip_url, error_summary, timings.

build_runs.status is constrained to pendingcompilingfillingverifyingdeployingsucceeded / failed (migration supabase/migrations/20260605120001_build_runs.sql). The generated file tree itself is stored in a Supabase Storage bucket keyed by build run, not in Postgres. Per-gap work units reuse acceptance_criteria + the kanban lifecycle — no new table. Each rebuild ties to a new project_changeset.

build_runs and app_integrations are not yet in the generated database_types.ts, so the route and orchestrator use supabase as any casts with biome-ignore comments. Regenerate types once the schema settles to remove them.

Surfaces

SurfacePathResponsibility
Build tab (server)app/project/[projectId]/build/page.tsxAuth + entitlement gate, loads integration status + latest run, renders the client.
Build tab (client)app/project/[projectId]/build/_components/build-client.tsxConnect checklist, output-target chooser, Build button, progress stepper (polls every 2.5 s), results panel.
Stage helpersapp/project/[projectId]/build/_lib/stages.tsPure stage/label/order helpers mirroring the build_runs.status CHECK constraint.
Trigger routeapp/api/projects/[projectId]/build/route.tsPOST authenticates, entitlement-checks, inserts a build_runs row, fires build/app.requested; GET returns runs for polling.
Orchestratorinngest/functions/build-app.tsFetches the blueprint, runs the pipeline, deploys, writes status back to build_runs.

The gating flag

Access is controlled by the buildApp feature flag in lib/billing/plans-config.ts, checked with canAccessFeature(planName, "buildApp") on both the API route and the Build page:

  • Free / Starterfalse.
  • Pro / Team / Enterprisetrue (App Builder is a Pro+ feature).
  • Admintrue (admins, resolved via the is_billing_exempt RPC, get all features).

Where to go next