SaaS.Dev

Building an App API first

Add a Lease Noun to leaseclock.com, call it from the CLI, SDK and MCP, watch a rule refuse a Verb, then see what the App shows for Lease.

An App starts as an API: you define a Noun and its Verb, call it on every Interface, and read the screens last. The steps continue the leaseclock.com Startup from Quickstart.

What you build

leaseclock.com tracks lease renewal dates for small property managers. You add the records it tracks: a Property Noun, and a Lease Noun with the Verbs Lease.renew and Lease.terminate. You create a lease, renew it once, and watch the second renewal refused by the rule the Verb carries.

Steps

  1. To start, open the leaseclock.com project from the quickstart. Without one, create it:

    npm create startups leaseclock.com -- --template solo-saas --yes
  2. To add the building a lease belongs to, write nouns/Property.mdx:

    nouns/Property.mdx
    ---
    properties:
      name: string
      address?: string
      units: integer
    ---
    A Property is one building a manager rents out, unit by unit.
  3. To add the lease, write nouns/Lease.mdx. Name the resident residentName, never tenant: a Tenant is a Customer's own space on the platform.

    nouns/Lease.mdx
    ---
    extends: [Thing]
    properties:
      residentName: string
      residentEmail?: email
      listingUrl?: url
      startsOn: date
      endsOn: date
      monthlyRent: number
      status: active | renewed | terminated
      notes?: text
      terms?: json
      documents?: string[]
      property: Property
    verbs:
      renew: {}
      terminate: {}
    ---
    A Lease is one resident's agreement on one unit of a Property, from signing to renewal.

    status lists the lifecycle in order, so create starts every lease at active. renew and terminate take only the record. The rule for renew lives in the Verb: it moves a lease from active to renewed, and from no other state.

  4. To give Lease.renew its Function, write nouns/Lease.renew.ts. It extends the end date by one year:

    nouns/Lease.renew.ts
    export default async (lease: Lease, input: LeaseRenewInput, $: $) => {
      const end = new Date(lease.endsOn)
      end.setFullYear(end.getFullYear() + 1)
      return $.Lease.update({ id: lease.$id, endsOn: end.toISOString().slice(0, 10) })
    }

    The Function never sets status. The Verb's Event, Lease.renewed, moves the lease when the call succeeds.

  5. To check both Nouns, start the dev server:

    npx startup-builder dev

    It parses the three files, previews them as one Change, and recompiles the API, SDK, CLI and MCP operations for Property and Lease.

  6. In a second terminal, create a property:

    npx startup-builder do Property.create --json '{"name":"14 Elm Street","units":6}'

    It returns property_1.

  7. To create a lease, call Lease.create. Each tab is the same call:

    curl -X POST https://api.sb/leaseclock.com/leases \
      -H "Authorization: Bearer $STARTUP_BUILDER_API_KEY" \
      -H "Content-Type: application/json" \
      -d '{"property":"property_1","residentName":"Maya Ortiz","residentEmail":"maya@example.com","startsOn":"2025-12-01","endsOn":"2026-11-30","monthlyRent":1450}'
    {
      "api": "https://api.sb/leaseclock.com",
      "lease": {
        "$id": "https://api.sb/leaseclock.com/leases/lease_1",
        "$type": "Lease",
        "property": "property_1",
        "residentName": "Maya Ortiz",
        "residentEmail": "maya@example.com",
        "startsOn": "2025-12-01",
        "endsOn": "2026-11-30",
        "monthlyRent": 1450,
        "status": "active"
      },
      "action": {
        "$id": "https://api.sb/leaseclock.com/actions/action_1",
        "operation": "Lease.create",
        "status": "Done"
      },
      "links": {
        "self": "https://api.sb/leaseclock.com/actions/action_1"
      }
    }

    Every call returns an Action. This one is Done, and the lease starts active.

  8. To renew the lease, call Lease.renew:

    npx startup-builder do Lease.renew --json '{"lease":"lease_1"}'

    The Action ends Done. Lease.renewed moves the lease to renewed, and endsOn reads 2027-11-30.

  9. To see the rule refuse, renew the same lease again:

    npx startup-builder do Lease.renew --json '{"lease":"lease_1"}' --output json
    {
      "type": "https://startupbuilder.dev/errors/refused",
      "title": "Refused",
      "status": 422,
      "detail": "Lease.renew moves a lease only from active; lease_1 is renewed.",
      "fix": "Read the record, run the Verb that moves it to an allowed state, then retry.",
      "instance": "https://api.sb/leaseclock.com/actions/action_3"
    }

    The Action ends Refused and the CLI exits with code 1. The API, SDK and MCP server return the same problem document, because every Interface calls the one Verb.

Result

leaseclock.com has two Nouns of its own, on four Interfaces, from two definition files and one Function. Nothing on any Interface was written by hand, and no Interface holds a rule the others lack.

The screens for the Startup's team read the same definition. These rules derive the parts of Lease, and its Renew button calls the Verb from step 8.

PartWhat Lease gets
ListOne row per lease, with the first 7 short properties as columns: residentName to status
Record titleresidentName, its first one-line text property
startsOn, endsOnDate inputs, required
monthlyRentA number input, required
notesA multi-line input, full width on the record page
termsFormatted on the record page, with no input
documentsThe record page only
propertyA reference to one Property, picked by search
Lease.renew, Lease.terminateA button on the record page, on the edit form and on each list row

Next steps

Contents