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, per-gap gapResults). 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.
Around the core pipeline:
- Dry-run plan —
GET /api/projects/[id]/build/planruns compile +lintBlueprint(no LLM, no writes) and powers the Build tab's plan card and the Prepare page's spec-check banner; it is also where the build route gets the real gap count it reserves tokens against. - Prepare gate — the build route 412s for unprepared projects unless the caller passes
force: true. - Acceptance-test pass — after verify goes green, the optional
VerifyDriver.runTestsruns the emittedtests/specs/Vitest suite in the temp app (--reporter=json, parsed byverify/parse-vitest.ts). Report-only: failures route to the owning gap by criterion ref ontoGapResult.testFailures; a crashing runner never fails a verified build. - Incremental rebuild — every
GapResultcarries aspecHash(sha256 of the gap's full context pack) and successful runs persist afiles.jsonsnapshot toapp-builder-artifacts. The next build'scompute-prefillsstep reuses files for gaps whose hash is unchanged and whose fill succeeded — no LLM call (GapResult.reused). Any failure degrades to a full rebuild. - Post-build chat edit —
POST /api/projects/[id]/build/edit+inngest/functions/build-edit.tspatch one gap of a succeeded run:buildEditPrompt(user request + current built files + the gap's spec context), ownership clamp, verify with one repair round, then snapshot update + full-tree push to the user's repo. Status lands on the gap'sgap_resultsentry (editStatus/lastEdit) so the existing run polling surfaces it.
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 for all three providers: 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 (builderRepoName) plus an idempotentcreateRepoWithFiles, and a ZIP-artifact producer uploads to theapp-builder-artifactsbucket with a 30-day retention prune after each successful run. The Inngest function runs withretries: 0— by design, a user must manually retry from the UI (cost control).
Per-user Supabase connect
lib/builder/integrations/supabase-mgmt.ts owns everything against the management API (https://api.supabase.com/v1) with the user's OAuth grant — there is no VibeMap-credential fallback anywhere; an unresolvable grant reads as "not connected" and the deploy keeps placeholders:
- OAuth — provider
supabasein the registry: PKCE S256 (verifier in an httpOnly cookie scoped to the callback), Basic-auth token exchange,response_type=code. RequiresSUPABASE_OAUTH_CLIENT_ID/SUPABASE_OAUTH_CLIENT_SECRET(OAuth app registered in the Supabase org dashboard); until set, the Build tab shows "Setup required". - Expiring grants — the Vault secret is a JSON grant
{access_token, refresh_token, expires_at};getSupabaseConnectiontransparently refreshes and rotates the secret (create new → repoint row → delete old; Vault has no update RPC). - Deploy target —
GET/POST /api/integrations/supabase/projectslists the user's projects and persists the chosen ref onapp_integrations.metadata.project_ref(validated against the live project list first). Deploy stampshttps://{ref}.supabase.co+ the anon key into the Vercel env viaresolveUserSupabaseEnv. - Schema apply (opt-in) — with
applySchema: trueon the build request (per-build checkbox, never remembered), a successful build runs the generatedsupabase/migrations/*.sqlin filename order viaPOST /v1/projects/{ref}/database/query; the first failure stops the sequence and is surfaced in the stage line + PostHog, without failing the build. - Kill switch — the whole feature sits behind the
builder_supabase_connectsubsystem flag.
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, which also defines the report columns gap_results / unsupported / ownership_violations). The generated file tree itself is stored in a Supabase Storage bucket keyed by build run ({userId}/{buildRunId}.files.json + .zip), not in Postgres. Per-gap state — including spec hashes, reuse, report-only test failures, and chat-edit status — lives inside the gap_results JSONB (shape: GapResult in lib/builder/types.ts), so iteration features need no extra tables.
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, enforces the prepare gate (force opt-out), reserves tokens against the real gap count, inserts a build_runs row, fires build/app.requested; GET returns runs for polling. |
| Plan route | app/api/projects/[projectId]/build/plan/route.ts | Dry-run compile + blueprint lint — the plan card, the Prepare spec-check banner, and the reservation size all read this. |
| Edit route | app/api/projects/[projectId]/build/edit/route.ts | POST queues a post-build chat edit for one gap (plan gate + per-edit token reservation), fires build/edit.requested. |
| Supabase picker | app/api/integrations/supabase/projects/route.ts | GET lists the connected user's Supabase projects; POST selects the deploy target. |
| Orchestrator | inngest/functions/build-app.ts | Fetches the blueprint, computes prefills, runs the pipeline, deploys (env stamping + opt-in schema apply), persists the snapshot, prunes artifacts, writes status back to build_runs. |
| Edit function | inngest/functions/build-edit.ts | Applies a verified chat edit to one gap: snapshot + context pack → LLM → clamp → verify → snapshot update + repo push. |
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.