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
-
To start, open the leaseclock.com project from the quickstart. Without one, create it:
npm create startups leaseclock.com -- --template solo-saas --yes -
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. -
To add the lease, write
nouns/Lease.mdx. Name the residentresidentName, nevertenant: 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.statuslists the lifecycle in order, socreatestarts every lease atactive.renewandterminatetake only the record. The rule forrenewlives in the Verb: it moves a lease fromactivetorenewed, and from no other state. -
To give
Lease.renewits Function, writenouns/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. -
To check both Nouns, start the dev server:
npx startup-builder devIt parses the three files, previews them as one Change, and recompiles the API, SDK, CLI and MCP operations for
PropertyandLease. -
In a second terminal, create a property:
npx startup-builder do Property.create --json '{"name":"14 Elm Street","units":6}'It returns
property_1. -
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 startsactive. -
To renew the lease, call
Lease.renew:npx startup-builder do Lease.renew --json '{"lease":"lease_1"}'The Action ends
Done.Lease.renewedmoves the lease torenewed, andendsOnreads2027-11-30. -
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
Refusedand the CLI exits with code1. 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.
| Part | What Lease gets |
|---|---|
| List | One row per lease, with the first 7 short properties as columns: residentName to status |
| Record title | residentName, its first one-line text property |
startsOn, endsOn | Date inputs, required |
monthlyRent | A number input, required |
notes | A multi-line input, full width on the record page |
terms | Formatted on the record page, with no input |
documents | The record page only |
property | A reference to one Property, picked by search |
Lease.renew, Lease.terminate | A button on the record page, on the edit form and on each list row |
Next steps
- API only or with an App: what each group of screens adds and costs, and when the API alone serves.
- Nouns: every key a Noun file takes, including Verb inputs.
- Reference: every
docommand and its JSON input. - The App: how each Persona shapes an App.
API only or with an App
Headless SaaS sells Software through its API, SDK, CLI, MCP server and webhooks with no App, and each set of screens added over that API costs more.
The App
How an App derives its screens from a Product's Nouns, Verbs and Personas, what derives today, and how Access Levels and slots shape it.