9.1 — App Builder architecture

9.1 — App Builder architecture

Contributor reference. This page documents the App Builder's internals — the compiler, the emitter registry, the gap-fill and verify stages. It assumes you have the repo checked out; file paths below are repo-relative.

The App Builder is shipped, not an admin preview. It's a Pro+ feature, gated on the buildApp flag: true on Pro, Team and Enterprise (and for admins), false on Free and Starter. If you're a user rather than a contributor, the guide you want is App Builder in the user docs.

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 planGET /api/projects/[id]/build/plan runs 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.runTests runs the emitted tests/specs/ Vitest suite in the temp app (--reporter=json, parsed by verify/parse-vitest.ts). Report-only: failures route to the owning gap by criterion ref onto GapResult.testFailures; a crashing runner never fails a verified build.
  • Incremental rebuild — every GapResult carries a specHash (sha256 of the gap's full context pack) and successful runs persist a files.json snapshot to app-builder-artifacts. The next build's compute-prefills step 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 editPOST /api/projects/[id]/build/edit + inngest/functions/build-edit.ts patch 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's gap_results entry (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 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 for all three providers: 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 (builderRepoName) plus an idempotent createRepoWithFiles, and a ZIP-artifact producer uploads to the app-builder-artifacts bucket with a 30-day retention prune after each successful run. The Inngest function runs with retries: 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 supabase in the registry: PKCE S256 (verifier in an httpOnly cookie scoped to the callback), Basic-auth token exchange, response_type=code. Requires SUPABASE_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}; getSupabaseConnection transparently refreshes and rotates the secret (create new → repoint row → delete old; Vault has no update RPC).
  • Deploy targetGET/POST /api/integrations/supabase/projects lists the user's projects and persists the chosen ref on app_integrations.metadata.project_ref (validated against the live project list first). Deploy stamps https://{ref}.supabase.co + the anon key into the Vercel env via resolveUserSupabaseEnv.
  • Schema apply (opt-in) — with applySchema: true on the build request (per-build checkbox, never remembered), a successful build runs the generated supabase/migrations/*.sql in filename order via POST /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_connect subsystem flag.

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, 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

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, 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 routeapp/api/projects/[projectId]/build/plan/route.tsDry-run compile + blueprint lint — the plan card, the Prepare spec-check banner, and the reservation size all read this.
Edit routeapp/api/projects/[projectId]/build/edit/route.tsPOST queues a post-build chat edit for one gap (plan gate + per-edit token reservation), fires build/edit.requested.
Supabase pickerapp/api/integrations/supabase/projects/route.tsGET lists the connected user's Supabase projects; POST selects the deploy target.
Orchestratorinngest/functions/build-app.tsFetches 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 functioninngest/functions/build-edit.tsApplies 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 / 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