9.1 — App Builder architecture

9.1 — App Builder architecture

🚧 Dark-launched (admin-only). The App Builder is a premium add-on gated behind the buildApp feature flag, which is currently false on every paid plan and true only for admins. 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.

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. Deploy reads process.env.GITHUB_PAT / VERCEL_TOKEN today — if they're absent the build runs in build-only mode (no deploy, graceful skip). The GA plan, marked with TODO(vibemap) in build-app.ts, is to read each user's per-provider tokens from app_integrations via Supabase Vault and stamp the user's own Supabase keys into the deployed app. Reusing one repo across rebuilds and a ZIP-artifact producer are also still TODO. 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. At GA this also custodies encrypted OAuth tokens.
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:

  • Starter / Profalse (currently a Pro+ feature, but dark-launched).
  • Team / Enterprisefalse, commented 🚧 Dark-launched — admin-only until deploy last-mile ships (then flip to true).
  • Admintrue (admins, resolved via the is_billing_exempt RPC, get all features).

So today the only accounts that can reach the Build tab are admins. When the connect-and-deploy last mile lands, the plan is to flip buildApp to true for Pro and above. Until then, treat the feature as internal preview — don't reference it as available in user-facing copy.

Where to go next