9.1 — App Builder architecture
On this page
9.1 — App Builder architecture
Pro+ feature. The App Builder is a premium add-on gated behind the
buildAppfeature flag, which istrueon Pro, Team, and Enterprise (and for admins) andfalseon 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
acceptanceCriteriaIdsbecome 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 function — compileBlueprint(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:
| Emitter | Blueprint slice | Emits |
|---|---|---|
scaffoldEmitter | project | package.json, config, Tailwind, app/layout, globals.css (pinned versions) |
schemaEmitter | tables + relationships | per-table migration (columns, FKs, indexes), typed row interface, CRUD server actions |
accessEmitter | access rules + page access rules | RLS policies (all / own / own_via / custom) and middleware route guards |
authEmitter | which pages are auth-required | @supabase/ssr module: login/signup/callback routes, session middleware, protected-route wrapper |
paymentsEmitter | payments flag | Stripe module — subscriptions + one-off payments + webhook handler (only when payments are enabled) |
pagesEmitter | pages / sections / navigation | route files + layout shells; pure-CRUD pages fully deterministic, others emit a shell + a Gap |
The registry has since grown with config-driven emitters — fileStorageEmitter, 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":
pagesEmitterandauthEmittermust agree on the auth mode. Pages callrequireUser()unconditionally, so forauthentication: "none"the auth emitter emits a no-oplib/auth/guards.ts(there is no/loginroute to redirect to) and the pages emitter omits the sign-out form (there is noapp/(auth)/actions.tsto import). This is covered bytests/unit/builder/import-integrity.test.ts— a 2026-07 regression where the two emitters disagreed made the deterministic scaffold failtscbefore 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/, implementEmitter, and register it inemitters/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 aslib/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 itstargetFilesvia the ownership manifest; writes outside the manifest are reported asownershipViolations, 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 viagetUserProviderToken(lib/builder/integrations/tokens.ts); tokens are captured by the OAuth callback atapp/api/integrations/[provider]/callback. For GitHub there is no silent fallback to VibeMap's sharedGITHUB_PAT(that would push the user's app into VibeMap's account) — the shared PAT is only used behind the internalBUILDER_ALLOW_SHARED_GITHUB_PATflag; without a token the build runs in build-only mode (no deploy, graceful skip). Vercel falls back toprocess.env.VERCEL_TOKEN. Repo reuse across rebuilds is handled by a stable per-project repo name plus an idempotentcreateRepoWithFiles, and a ZIP-artifact producer uploads to theapp-builder-artifactsbucket. 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 withretries: 0— by design, a user must manually retry from the UI (cost control).
Data model
| Table | Purpose |
|---|---|
app_integrations | Per-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_runs | One 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 pending → compiling → filling → verifying → deploying → succeeded / 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_runsandapp_integrationsare not yet in the generateddatabase_types.ts, so the route and orchestrator usesupabase as anycasts withbiome-ignorecomments. Regenerate types once the schema settles to remove them.
Surfaces
| Surface | Path | Responsibility |
|---|---|---|
| Build tab (server) | app/project/[projectId]/build/page.tsx | Auth + entitlement gate, loads integration status + latest run, renders the client. |
| Build tab (client) | app/project/[projectId]/build/_components/build-client.tsx | Connect checklist, output-target chooser, Build button, progress stepper (polls every 2.5 s), results panel. |
| Stage helpers | app/project/[projectId]/build/_lib/stages.ts | Pure stage/label/order helpers mirroring the build_runs.status CHECK constraint. |
| Trigger route | app/api/projects/[projectId]/build/route.ts | POST authenticates, entitlement-checks, inserts a build_runs row, fires build/app.requested; GET returns runs for polling. |
| Orchestrator | inngest/functions/build-app.ts | Fetches 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 / Starter —
false. - Pro / Team / Enterprise —
true(App Builder is a Pro+ feature). - Admin —
true(admins, resolved via theis_billing_exemptRPC, get all features).
Where to go next
- The data model the compiler reads → 2.2 The data model in plain English.
- How the rest of the pipeline works → 2.1 How VibeMap works.
- How architecture choices drive the build → 9.2 Architecture configuration.
- The user-facing guide → User docs: 12 App Builder.