Skip to content

Case Study: SaaS MVP

Background

Two founders — one ex-facility-ops, one full-stack — building FitMaintain: SaaS for gym chains to schedule equipment maintenance, log technician visits, and bill locations per machine. Stack locked early: Next.js App Router, Fastify API, Postgres, Stripe. Target: first paying gym in three weeks. Neither had shipped with coding agents before; both had shipped products the slow way.

They had a Friday investor coffee. The agent session started Thursday night.

Initial vague prompt

text
Build a SaaS for gym equipment maintenance. Next.js frontend, Fastify
backend, Postgres, Stripe. Users should manage gyms, equipment, schedule
maintenance, and pay. Make it look polished — we have a demo tomorrow.

Claude Code ran for ~90 minutes. It produced a monorepo, Prisma schema, auth pages, a dashboard with charts, Stripe Checkout wiring, and seed data for "Iron Temple Fitness." The UI looked like a product. They slept.

Why it failed

Friday morning, the demo died in the first five minutes:

ClickWhat happened
Create gym500 — gyms.owner_id NOT NULL, seed user had no row in users after a schema drift the agent "fixed" overnight
Schedule maintenanceForm submitted; row appeared; technician assignment was a free-text string, not a user FK
Stripe "Subscribe"Checkout session created against test keys hardcoded in the client bundle
Refresh dashboardCharts showed hardcoded JSON from lib/demoMetrics.ts — not queries

They had a 6-hour demo, not an MVP. Rough accounting afterward: ~2% of the surface area a paying gym would need, dressed as 80%. The agent optimized for visual completeness under a vague prompt. That is the correct local optimum for "make it look polished." It is the wrong optimum for "ship something that survives a second click."

Restarting with momentum ("just fix the bugs") would have compounded the lie. They wiped apps/ and kept only the repo rails: CI, CLAUDE.md, empty schema, deploy to Fly.

The improved spec

They wrote specs/PLAN.md and three slice specs before the next agent session. Abridged from specs/003-schedule-maintenance.md:

markdown
## Slice 003 — Schedule maintenance visit

### Done when
- Authenticated owner can POST /v1/gyms/:id/visits with {equipmentId, windowStart, windowEnd}
- Visit appears in GET /v1/gyms/:id/visits
- equipmentId must belong to that gym (403 otherwise)
- Overlapping windows for same equipment rejected (409)
- Next.js page at /gyms/[id]/visits creates and lists visits
- Integration test covers happy path + overlap + cross-gym equipment

### Out of scope
- Technician assignment, notifications, Stripe, PDF reports, charts

### Quality tier
- Auth: production (already shipped in 001)
- Billing: stub — do not touch Stripe
- Email: console logger only

Quality tiers lived in PROJECT_CONTEXT.md so the agent could not "helpfully" finish Stripe mid-slice. See Specs over Prompts and the MVP Build playbook.

Agent workflow

Each session opened with an explicit instruction to read CLAUDE.md, PROJECT_CONTEXT.md, and the slice spec — and to list adjacent problems without fixing them. Sessions that tried to "also clean up the dashboard" were reverted.

Stripe stayed a FakeBillingProvider until slice 009, after visits and equipment CRUD were verified on the deployed skeleton. The investor coffee was rescheduled; the first gym signed a pilot in week three.

Human intervention points

  1. After the wipe — founders wrote PLAN.md themselves. The agent was not asked to invent the product shape.
  2. Between every slice — one founder ran the "Done when" checklist by hand on the Fly URL, not localhost.
  3. Before Stripe — they reviewed the billing interface and webhook signature verification personally; the agent implemented against a frozen interface.
  4. Before pilot — they deleted lib/demoMetrics.ts and any seed that faked aggregates. If a number was on screen, it came from SQL.

Mistakes made

  • Trusting visual completeness. Cost: one lost demo day + two days of emotional recovery before the restart. The charts were the tell; nobody asked "where does this number come from?"
  • Hardcoded secrets in the first agent pass. Test Stripe keys in NEXT_PUBLIC_*. Caught only because the second founder grepped the client bundle. Would have been a production incident if they had "fixed forward."
  • One mega-session on the restart attempt (day 8). Context rot: the agent "remembered" a previous schema that no longer existed and spent 40 minutes fighting Prisma migrate. Fix: one slice per session, always.
  • Skipping characterization of the fake billing stub. Slice 009 assumed the stub's charge() signature; an earlier session had renamed it. Two hours of thrash until they pinned the interface in PROJECT_CONTEXT.md.

Final outcome

Week 3 deliverable: one gym org, equipment CRUD, visit scheduling with overlap rules, invite-only auth, Stripe subscription for the org (not per-machine — deferred), deployed, CI green, 47 integration tests. No charts. The pilot gym's ops manager used it for two weeks before asking for technician assignment — which became slice 012 under a new spec, not a hallway prompt.

Line counts at pilot: ~6.2k LOC application code, ~1.8k LOC tests. Roughly 70% agent-typed, 100% human-gated.

Lessons

  1. A polished demo under a vague prompt is evidence of agent competence at UI theater, not product progress. Measure slices verified end-to-end on the real deploy target.
  2. Record quality tiers in writing before the agent touches billing, auth, or email — otherwise it will silently harden or silently stub based on whatever file it saw last.
  3. Wipe and restart when the artifact is a lie. Fixing forward from fake charts and client-side secrets costs more than a clean walking skeleton.
  4. Fresh session per slice beats a heroic overnight session; context rot looks like the agent being "bad at Prisma."
  5. Investor demos are a forcing function for the wrong optimum. Move the date before you move the definition of done.

A field manual for AI-native software engineering.