--- url: https://saas.dev/docs md: https://saas.dev/docs.md --- # Overview Most of a SaaS is the same as every other: sign-in, an admin, an API, docs, a site and billing. A coding agent can write all of it, and then you maintain all of it. With SaaS.Dev you write only what is yours, the things your product keeps and what people do to them, and the rest is generated and run for you. It fits products made of records and the things people do to them: trackers, CRMs, booking tools, client portals and back offices. ## Leaseclock Leaseclock tracks lease renewals for property managers, and it runs at [leaseclock.com](https://leaseclock.com). Northgate Rentals manages its 24 units with it, and its leasing coordinator, Priya Shah, renews each lease there before it lapses. Every example in the docs is Leaseclock. Its whole definition is three short files: **Lease.mdx** ```mdx title="leaseclock.com/Noun/Lease.mdx" --- name: Lease fields: unit: { type: link, target: Unit, required: true } renter: { type: email, required: true } endsOn: { type: date, required: true, indexed: true } status: { type: choice, options: [{ key: active }, { key: ended }] } verbs: renew: does: Move the end date forward input: { endsOn: { says: the new end date, type: date, required: true } } from: [active] end: { does: End the lease, from: [active], to: ended } --- ``` **Unit.mdx** ```mdx title="leaseclock.com/Noun/Unit.mdx" --- name: Unit fields: label: { type: text, required: true } building: { type: text } --- ``` **pro.mdx** ```mdx title="leaseclock.com/Offer/pro.mdx" --- name: Leaseclock Pro price: { amount: 29, currency: USD, unit: month } limits: { Unit: 200 } personas: Property manager, Leasing coordinator status: Live --- ``` A Unit and a Lease are the things Leaseclock keeps, and SaaS.Dev calls them [Nouns](https://saas.dev/docs/nouns.md). A Lease links to a Unit, holds the renter's email and ends on a date. `renew` and `end` are what people do to a Lease, and SaaS.Dev calls them [Verbs](https://saas.dev/docs/verbs.md): `renew` moves the end date forward, and `end` closes the lease. Leaseclock Pro is an [Offer](https://saas.dev/docs/offers.md), the plan a property manager buys: $29 a month for up to 200 units. Everything else Leaseclock serves is generated from these files and run by SaaS.Dev. ## What is generated Each surface has its own page, with Leaseclock's version of it: the [App](https://saas.dev/docs/app.md), the [API](https://saas.dev/docs/api.md), the [CLI](https://saas.dev/docs/cli.md), the [MCP server](https://saas.dev/docs/mcp.md), the [SDK](https://saas.dev/docs/sdk.md), the [Site](https://saas.dev/docs/site.md), [Your docs](https://saas.dev/docs/your-docs.md), the [Blog](https://saas.dev/docs/blog.md) and [Stripe](https://saas.dev/docs/stripe.md) billing. Every surface is on your domain and in your brand. Leaseclock wears its own teal, and nothing its customers see names SaaS.Dev. Then make it yours: [Defaults and overrides](https://saas.dev/docs/overrides.md), [Constraints](https://saas.dev/docs/constraints.md), [How it works](https://saas.dev/docs/how-it-works.md) and [Errors](https://saas.dev/docs/errors.md). ## Start The [Quickstart](https://saas.dev/docs/quickstart.md) takes your own product from an empty folder to live on your domain, starting from Leaseclock. With a coding agent, paste this prompt and name what your product keeps: ```text title="Prompt" Read https://saas.dev/AGENTS.md. Then build me a SaaS on SaaS.Dev that tracks [what your product keeps]: start with `npx saas.dev init my-product --from leaseclock.com`, push to Test, fix every error, and send me the Test URL it prints. Go Live only through the approval link, when I say so. ``` Every command is on the [`npx saas.dev`](https://saas.dev/docs/saas-dev-cli.md) page, and [Build with your agent](https://saas.dev/docs/agents.md) connects your agent to SaaS.Dev's MCP server. --- url: https://saas.dev/docs/quickstart md: https://saas.dev/docs/quickstart.md --- # Quickstart Three commands, and each one ends on an address you can open. Leaseclock tracks lease renewals for property managers, and it runs at [leaseclock.com](https://leaseclock.com). You start from a copy of it in Test, make it yours, and finish with your own product on your own domain, taking payments into your own Stripe account. You need Node.js 20 or later and an empty folder. Your first product is free, and nothing in Test charges a real card. Every command is on the [`npx saas.dev`](https://saas.dev/docs/saas-dev-cli.md) page. With a coding agent, paste the prompt and it runs the same three steps. [Build with your agent](https://saas.dev/docs/agents.md) covers the rest. ```text title="Prompt" Read https://saas.dev/AGENTS.md. Then build me a SaaS on SaaS.Dev that tracks [what your product keeps]: start with `npx saas.dev init my-product --from leaseclock.com`, push to Test, fix every error, and send me the Test URL it prints. Go Live only through the approval link, when I say so. ``` ## Start from Leaseclock ```console title="Terminal" $ npx saas.dev init my-product --from leaseclock.com Signed in as you@example.com Created my-product in Test from leaseclock.com Noun/Lease.mdx Noun/Unit.mdx Offer/pro.mdx AGENTS.md https://my-product.test.saas.dev ``` The first run opens a browser tab on api.saas.dev to sign you in, and the CLI keeps an id.org.ai token for the machine. The folder `my-product/` holds Leaseclock's files, and `my-product.test.saas.dev` already runs them with Leaseclock's Test records: sign-in, the app, the admin, the API, docs, a home page and a pricing page. [Leaseclock's Leases list for Northgate Rentals, on its Active tab, with 4B renewed to Nov 30, 2027 and Undo](https://leaseclock.com/app/leases) In CI, set `SAAS_DEV_TOKEN` in the environment and the CLI uses it instead of a browser. ### What you started Leaseclock keeps two things: Units, the apartments a property manager rents out, and Leases, which tie a renter to a Unit until a date. Each is a Noun. A Lease can be renewed or ended, and those are its Verbs. ```mdx title="Noun/Lease.mdx" --- name: Lease fields: unit: { type: link, target: Unit, required: true } renter: { type: email, required: true } endsOn: { type: date, required: true, indexed: true } status: { type: choice, options: [{ key: active }, { key: ended }] } verbs: renew: does: Move the end date forward input: { endsOn: { says: the new end date, type: date, required: true } } from: [active] end: { does: End the lease, from: [active], to: ended } --- ``` `renew` has no function, so it does what it declares: it writes the new `endsOn` onto the lease. The plan customers buy is an Offer, Leaseclock Pro, at $29 a month for up to 200 Units.
Noun/Unit.mdx and Offer/pro.mdx ```mdx title="Noun/Unit.mdx" --- name: Unit fields: label: { type: text, required: true } building: { type: text } --- ``` ```mdx title="Offer/pro.mdx" --- name: Leaseclock Pro price: { amount: 29, currency: USD, unit: month } limits: { Unit: 200 } personas: Property manager, Leasing coordinator status: Live --- ```
### Buy it in Test Open `my-product.test.saas.dev/pricing` and press **Subscribe**. Stripe Checkout opens in Leaseclock's teal. In Test it runs on a Stripe test account SaaS.Dev opens for your product, so it takes Stripe's test card and needs no Stripe account of your own. | Card | Expiry | CVC | | --- | --- | --- | | `4242 4242 4242 4242` | Any future date | Any three digits | Checkout returns you to the app, and the subscription shows on the admin's Billing screen. ## Make it yours Rename Lease to what your product keeps, change a field, and push. Here the product books appointments: rename `Noun/Lease.mdx` to `Noun/Booking.mdx`, change `name: Lease` to `name: Booking` and `endsOn` to `startsAt`, and name the plan `Booking Pro` in `Offer/pro.mdx`. ```console title="Terminal" $ npx saas.dev push my-product · Test ~ Lease → Booking /app/leases → /app/bookings · $.Lease → $.Booking ~ endsOn → startsAt every screen, the API, the SDK and the docs ~ Leaseclock Pro → Booking Pro the pricing page and the Stripe product Push these changes to Test? (y/N) y Pushed version 2 · https://my-product.test.saas.dev ``` SaaS.Dev shows the change on every surface and waits for a yes before it saves anything. ### When a file is wrong Every file is checked before anything is saved, and every error comes back at once, each with the file, the line and the fix: ```console title="Terminal" $ npx saas.dev push 422 Booking.mdx line 6: field "StartsAt" must start lower-case. Rename it to "startsAt". $ npx saas.dev push --fix Pushed version 2 · https://my-product.test.saas.dev ``` `--fix` applies each error's edit, then previews. Test stays as it was until a push passes. Every code and its fix is in [Errors](https://saas.dev/docs/errors.md). ## Go live Add your domain, connect your Stripe account and push to Live: ```console title="Terminal" $ npx saas.dev domain add bookings.example.com Add this record at your DNS host: CNAME bookings.example.com my-product.saas.dev ✓ bookings.example.com answers, with its certificate $ npx saas.dev stripe connect ✓ Stripe connected · acct ••••4f2a $ npx saas.dev push --live Booking Pro sells at $29 a month Approve at https://api.saas.dev/approve/v2 Approved by you@example.com · Live serves version 2 Live · https://bookings.example.com ``` Your product answers at `bookings.example.com`, and Test moves to `test.bookings.example.com`. When a customer subscribes to Booking Pro, the payment lands in your Stripe account, under your product's name, and shows on the admin's Billing screen as Paid. SaaS.Dev takes no share of it. ## Next - [Build with your agent](https://saas.dev/docs/agents.md): the MCP server, AGENTS.md and every key a file takes. - [Nouns](https://saas.dev/docs/nouns.md) and [Verbs](https://saas.dev/docs/verbs.md): describe what your own product keeps and what people do to it. - [Stripe](https://saas.dev/docs/stripe.md): refunds, trials, taxes and what your customers see. - [Defaults and overrides](https://saas.dev/docs/overrides.md): change the theme, or replace a screen with your own React component. --- url: https://saas.dev/docs/agents md: https://saas.dev/docs/agents.md --- # Build with your agent Your coding agent builds and ships your product the way you would: it writes the files, pushes them to Test, fixes every error from the line and the fix each one names, and sends you the Test URL. Live waits for you. Paste this into Claude Code, Cursor or any agent that runs terminal commands: ```text title="Prompt" Read https://saas.dev/AGENTS.md. Then build me a SaaS on SaaS.Dev that tracks [what your product keeps]: start with `npx saas.dev init my-product --from leaseclock.com`, push to Test, fix every error, and send me the Test URL it prints. Go Live only through the approval link, when I say so. ``` The agent can run [`npx saas.dev`](https://saas.dev/docs/saas-dev-cli.md) in a terminal, or call SaaS.Dev's MCP server. ## Connect SaaS.Dev's MCP server is `https://api.saas.dev/mcp`, over streamable HTTP. The first call opens SaaS.Dev's sign-in page, and the agent then works as you, on your products. **Claude Code** ```bash title="Terminal" claude mcp add --transport http saas-dev https://api.saas.dev/mcp ``` **Claude** In **Settings → Connectors**, choose **Add custom connector**, name it SaaS.Dev and paste the address: ```text https://api.saas.dev/mcp ``` **Cursor** ```json title=".cursor/mcp.json" { "mcpServers": { "saas-dev": { "url": "https://api.saas.dev/mcp" } } } ``` **VS Code** ```json title=".vscode/mcp.json" { "servers": { "saas-dev": { "type": "http", "url": "https://api.saas.dev/mcp" } } } ``` An agent on your own machine can run the same tools over stdio with `npx saas.dev mcp`, signed in as the CLI is. In CI, send `SAAS_DEV_TOKEN` as a bearer token in place of signing in. ## The tools SaaS.Dev's server has the same three tools as every product it generates: `search` and `fetch` read your products, their versions and their errors, and `do` runs a platform Verb. A Verb's input is checked before it runs, and every write lands in your product's history. ### check Checks files without saving anything, and returns every error at once. | Input | Type | What it does | | --- | --- | --- | | `product` | string | The product, such as `my-product` | | `files` | object | Each path and its text, such as `{ "Noun/Lease.mdx": "…" }` | ### preview Returns what a push would change on each surface, and whether any change is breaking. | Input | Type | What it does | | --- | --- | --- | | `product` | string | The product | | `files` | object | The files to compare with the version Test serves | ### push Checks the files and saves them as a new version in Test. It returns `{ ok, version, url }`, or `{ ok: false, errors }` with nothing saved. | Input | Type | What it does | | --- | --- | --- | | `product` | string | The product | | `files` | object | The files | | `fix` | boolean | Applies every error's edit first | | `yes` | boolean | Confirms a breaking change | ### rollback Serves an earlier version again, as a new version. | Input | Type | What it does | | --- | --- | --- | | `product` | string | The product | | `version` | number | The version to serve | | `live` | boolean | Rolls Live back instead of Test | ### go_live Asks to serve a version Test already runs in Live. It returns an approval link for you to open. The version reaches customers only after you approve it there. | Input | Type | What it does | | --- | --- | --- | | `product` | string | The product | | `version` | number | The version, which must already run in Test | Versions are records, read with `search`: `{ "type": "Version", "where": { "product": "my-product" } }` lists them with who pushed each and where it runs. ## The file your agent reads first `npx saas.dev init` writes `AGENTS.md` into your product's folder, and the same file is at [saas.dev/AGENTS.md](https://saas.dev/AGENTS.md): ````md title="AGENTS.md" # AGENTS.md This folder is a product on SaaS.Dev. Its definition is the Markdown files in it: each file in `Noun/` is something the product keeps, with its fields and its Verbs, and each file in `Offer/` is a plan its customers buy. SaaS.Dev generates the app, API, CLI, MCP server, SDK, site, docs, blog and Stripe billing from these files, and runs them. None of the generated code lands in this folder. ## How to work here 1. Change the product by editing the files in `Noun/` and `Offer/`. Write only the keys in https://saas.dev/schema/noun.json and https://saas.dev/schema/offer.json (copies sit in `.saas/schema/`). 2. Push to Test: `npx saas.dev push`. Every file is checked, and every error comes back at once, each with its file, line, fix and edit. 3. Fix them all: `npx saas.dev push --fix` applies each error's edit, then previews. 4. Read what changes before it is saved: `npx saas.dev push --preview --json`. 5. Send the person the Test URL the push prints. ## What needs the person - Live. `npx saas.dev push --live` prints an approval link, and Live serves the version only after the person approves it there. - Tokens. The CLI keeps the person's sign-in for this machine. CI reads `$SAAS_DEV_TOKEN`. Write no token into a file. - Money. `npx saas.dev stripe connect` opens Stripe for the person to sign in. ## The shape of a file ```mdx title="Noun/Lease.mdx" --- name: Lease fields: unit: { type: link, target: Unit, required: true } renter: { type: email, required: true } endsOn: { type: date, required: true, indexed: true } status: { type: choice, options: [{ key: active }, { key: ended }] } verbs: renew: does: Move the end date forward input: { endsOn: { says: the new end date, type: date, required: true } } from: [active] end: { does: End the lease, from: [active], to: ended } --- ``` A Noun's name is PascalCase and singular. Field keys and Verb names are camelCase. A Verb with no function writes its input onto the record. ## An error ```json title="npx saas.dev push --json" { "ok": false, "errors": [ { "code": "invalid_name", "file": "Noun/Lease.mdx", "line": 6, "message": "field \"EndsOn\" must start lower-case.", "fix": "Rename it to \"endsOn\".", "edit": { "file": "Noun/Lease.mdx", "line": 6, "from": "EndsOn", "to": "endsOn" }, "docs": "https://saas.dev/docs/errors#invalid_name" } ] } ``` The command exits 1 on any error, and 2 on a breaking change without `--yes`. ## Commands - `npx saas.dev init [folder] [--from ]`: Asks for a name, signs you in on first run, starts from Leaseclock unless --from names another product, writes its files and AGENTS.md, and prints its Test URL - `npx saas.dev login`: Signs you in through the browser and stores an id.org.ai token - `npx saas.dev pull [name] [version]`: Writes a product's definition, or an earlier version, into a folder, with .saas/types.d.ts and .saas/schema/ - `npx saas.dev push`: Checks every file and pushes a new version to Test - `npx saas.dev push --preview`: Checks every file and prints what changes on each surface, saving nothing - `npx saas.dev push --live`: Pushes the version in Test to Live, after a preview - `npx saas.dev versions`: Lists every version with who pushed it and where it runs - `npx saas.dev rollback [--live]`: Serves an earlier version again, as a new version - `npx saas.dev open [path]`: Opens the product in Test in the browser - `npx saas.dev domain add `: Serves the product on your own domain, with the DNS record to add - `npx saas.dev stripe connect`: Connects your Stripe account, once, for Test and Live - `npx saas.dev token create [--live]`: Creates a token for CI, which pushes to Test unless created with --live - `npx saas.dev token revoke `: Ends a token - `npx saas.dev export`: Downloads the definition and every record in one file - `npx saas.dev mcp`: Serves SaaS.Dev's MCP tools over stdio, for an agent on this machine ## More - Every docs page as Markdown: https://saas.dev/llms.txt - Every key a file takes: https://saas.dev/docs/agents.md - Every error code: https://saas.dev/docs/errors.md ```` ## A session ```console title="Claude Code" > Build me a SaaS that tracks dog-walking bookings. ● Reading https://saas.dev/AGENTS.md ● npx saas.dev init my-product --from leaseclock.com https://my-product.test.saas.dev ● Renaming Lease to Booking, endsOn to startsAt, adding walker ● npx saas.dev push --json { "ok": false, "errors": [ { "code": "unknown_type", "file": "Noun/Booking.mdx", "line": 8, "fix": "Use \"team\" for a person in the workspace." } ] } ● npx saas.dev push --fix Pushed version 2 · https://my-product.test.saas.dev Your product runs in Test at https://my-product.test.saas.dev. Bookings have a start time, a walker and a status. Say when you want it Live, and open the approval link I send. ``` ## Without MCP Every command takes `--json` and prints one JSON object, so an agent reads results without parsing a terminal. ```bash title="Terminal" npx saas.dev push --json npx saas.dev push --preview --json ``` A push returns `{ ok, errors }`. Each error carries `code`, `file`, `line`, `message`, `fix`, `edit: { file, line, from, to }` and `docs`, and `push --fix` applies every `edit`. A preview returns `{ version, changes: [{ surface, change, breaking }], breaking }`. | Exit code | When | | --- | --- | | `0` | The command did what it says | | `1` | Any error, such as a file that breaks a rule | | `2` | A breaking change without --yes | Without a terminal, `push --live` prints an approval link and waits until you approve it. Only a token made with `token create --live` pushes to Live without one. ## Every key Every key a definition file takes. The same grammar is JSON Schema at [saas.dev/schema/noun.json](https://saas.dev/schema/noun.json) and [saas.dev/schema/offer.json](https://saas.dev/schema/offer.json), which `init` and `pull` save to `.saas/schema/`. | In | Key | Takes | What it does | | --- | --- | --- | --- | | Noun | `name` | PascalCase, singular | The Noun: the list, the API path, the SDK class and the docs page take their names from it | | Noun | `fields` | a map of field key to field | What each record keeps | | Noun | `verbs` | a map of Verb name to Verb | What people and agents do to a record | | Field | `type` | one of the field types | What the field holds, and the input, column and JSON it gets | | Field | `required` | true | A record cannot be saved without it | | Field | `indexed` | true | Sorts and filters the list, and takes .before and .after on dates | | Field | `many` | true | Holds a list of values | | Field | `max` | a number | The longest text, or the most values in a list | | Field | `pattern` | a regular expression | Every value must match it | | Field | `rich` | true | Allows formatting in longText | | Field | `options` | a list of { key, label } | The options of a choice or choices field | | Field | `currency` | a three-letter code | A money field's currency, when it is not the Offer's | | Field | `decimals` | a number | Decimal places shown and stored | | Field | `range` | { min, max } | The least and most a number may be | | Field | `unit` | text | A number's unit, shown beside it | | Field | `start` | a number | An autoNumber's first value | | Field | `classification` | NAICS, O*NET or UNSPSC | The standard a classification field draws from | | Field | `target` | a Noun | The Noun a link points at | | Field | `onDelete` | block, clear or cascade | What happens to linking records when the target is deleted | | Field | `reverseLabel` | text | The heading on the target's page that lists linking records | | Field | `takes` | people, agents or both | Who a team field can hold | | Field | `accept` | a list of file types | The files a file field takes | | Field | `maxMb` | a number up to 25 | A file's largest size | | Field | `lookup` | { via, field } | Reads a field through a link | | Field | `rollup` | { via, fn, field } | Counts or adds up over linking records | | Verb | `does` | one sentence | What the Verb does: its button title, docs line and tool description | | Verb | `input` | a map of input key to { says, type, required } | What the Verb asks for | | Verb | `from` | a list of states | The states the Verb runs from | | Verb | `to` | a state | The state the Verb leaves the record in | | Offer | `name` | text | The plan's name on the pricing page, at checkout, in Stripe and on every invoice | | Offer | `price` | { amount, currency, unit } | The price in whole units of the currency, billed each month or year | | Offer | `limits` | a map of Noun to a number | The most records of each Noun one customer may hold | | Offer | `personas` | text | Who the plan is for | | Offer | `status` | Draft, Live or Retired | Where the Offer sells | | Field type | `text` | max, pattern | One line | | Field type | `longText` | max, pattern, rich | Paragraphs | | Field type | `email` | pattern | An email address, checked on save | | Field type | `phone` | pattern | A phone number, shown as a call link | | Field type | `url` | pattern | A web address, shown as a link | | Field type | `number` | unit, decimals, range | A count or a measure | | Field type | `money` | currency, decimals, range | An amount in a currency's main unit | | Field type | `percent` | decimals, range | A rate | | Field type | `autoNumber` | start | A number assigned on create, counting up | | Field type | `date` | · | A day | | Field type | `dateTime` | · | A moment, stored in UTC | | Field type | `month` | · | A month | | Field type | `yesNo` | · | Yes or no | | Field type | `choice` | options | One option from a list | | Field type | `choices` | options | Any of the options | | Field type | `classification` | classification | A standard code | | Field type | `link` | target, many, onDelete, reverseLabel | Another record | | Field type | `team` | many, takes | People or agents in the customer's workspace | | Field type | `file` | accept, maxMb, many | An uploaded file | | Field type | `lookup` | lookup | A field read through a link | | Field type | `rollup` | rollup | A count or sum over linked records | --- url: https://saas.dev/docs/nouns md: https://saas.dev/docs/nouns.md --- # Nouns A Noun is something your product keeps track of. Leaseclock has two: a Unit, which is an apartment, and a Lease, which ties a renter to a Unit until a date. A Noun's name becomes its URL, its screen title, its SDK method and its CLI command. ## A Noun file Each Noun is one file in your product's `Noun/` folder. This is Leaseclock's Lease, in full: ```yaml title="Noun/Lease.mdx" --- name: Lease fields: unit: { type: link, target: Unit, required: true } renter: { type: email, required: true } endsOn: { type: date, required: true, indexed: true } status: { type: choice, options: [{ key: active }, { key: ended }] } verbs: renew: does: Move the end date forward input: { endsOn: { says: the new end date, type: date, required: true } } from: [active] end: { does: End the lease, from: [active], to: ended } --- ``` `name` and `fields` describe what a lease holds. `verbs` lists what people do to a lease, and [Verbs](https://saas.dev/docs/verbs.md) covers each line. Text after the closing `---` opens the Lease page in your docs at [leaseclock.com/docs/lease](https://leaseclock.com/docs/lease). Push it with [`npx saas.dev push`](https://saas.dev/docs/saas-dev-cli.md), and leases are live in Test on every surface: ```bash title="Terminal" npx saas.dev push ``` **API** ```bash curl https://leaseclock.com/api/leases/4b \ -H "Authorization: Bearer $LEASECLOCK_TOKEN" ``` ```json { "$id": "https://leaseclock.com/api/leases/4b", "$type": "Lease", "unit": "4b", "renter": "dana.whitfield@example.com", "endsOn": "2026-11-30", "status": "active" } ``` **CLI** ```bash npx leaseclock show leases 4b ``` ```text Lease 4B unit 4B, Alder Court renter dana.whitfield@example.com endsOn 2026-11-30 status active ``` **SDK** ```ts title="lease.ts" import { Leaseclock } from 'leaseclock' const $ = new Leaseclock({ token: process.env.LEASECLOCK_TOKEN }) const lease = await $.Lease.get('4b') lease.endsOn // '2026-11-30' lease.status // 'active' ``` In the app, Priya Shah, the leasing coordinator at Northgate Rentals, opens the same lease at [leaseclock.com/app/leases/4b](https://leaseclock.com/app/leases/4b). Your customers' agents find it with the MCP server's `search` tool and read it with `fetch`. ## Names The `name` line sets what every surface calls the Noun. Change it and push, and every surface uses the new name. | Where | `name: Lease` becomes | | --- | --- | | App | "Leases" in the sidebar, at `leaseclock.com/app/leases` | | API | `leaseclock.com/api/leases`, and `"$type": "Lease"` on every record | | SDK | `$.Lease`, with `list`, `get`, `create` and `update`, plus a method for each Verb | | CLI | `npx leaseclock show leases` and `npx leaseclock do Lease.renew` | | MCP | `Lease` as a type in `search` and `fetch`, and `Lease.renew` in `do` | | Docs | A page at `leaseclock.com/docs/lease` | A name is singular, starts with a capital letter, and holds letters and digits only, up to 64 characters. The URL uses the plural in lower case: Lease becomes `leases`, and a Noun named Property becomes `properties`. Write a two-word name joined, as `RentReview`, and screens show it as "Rent review". A name that breaks a rule returns an error with the line and the fix: ```text 422 Lease.mdx line 2: name "lease" must start upper-case. Rename it to "Lease". ``` Every error code links to its fix in [Errors](https://saas.dev/docs/errors.md), so you or your agent correct the file and push again. ## Fields `fields` lists what each record holds. A field is a key, a type and any settings the field needs: ```yaml endsOn: { type: date, required: true, indexed: true } ``` A key starts with a lower-case letter and joins words in camelCase. Screens label the field from its key, so `endsOn` shows as "Ends on". Each line of Lease's fields sets what people see and what the API returns: | Line | In the app | In the API | | --- | --- | --- | | `unit: { type: link, target: Unit, required: true }` | A Unit column, and a Unit picker in the form | `"unit": "4b"`, filtered with `unit=4b` | | `renter: { type: email, required: true }` | A mail link, and an input that accepts only an address | `"renter": "dana.whitfield@example.com"` | | `endsOn: { type: date, required: true, indexed: true }` | A date column the list sorts by, and an "Ends before" filter | `"endsOn": "2026-11-30"`, filtered with `endsOn.before=2027-01-01` and sorted with `sort=endsOn` | | `status: { type: choice, options: [...] }` | A badge on each row, and a tab for each option | `"status": "active"`, filtered with `status=active` | ### Settings every field takes | Setting | What it does | | --- | --- | | `required: true` | The record saves only with a value. The form marks the field, and an empty value returns a 422 naming it. | | `indexed: true` | Lists sort and filter by the field at any size. The first indexed field sets a list's default order. | | `label` | The name on screens and in docs, when the key's own words are wrong: `label: Move-out date`. | | `description` | Help text under the input, on the docs page, and in the SDK type your editor shows on hover. | | `default` | The value a new record starts with: `default: active`. | | `requiredWhen` | Required only when other fields match: `requiredWhen: { status: ended }`. | ### Add a field Add one line and push. Leaseclock gains rent: ```diff title="Noun/Lease.mdx" fields: unit: { type: link, target: Unit, required: true } renter: { type: email, required: true } endsOn: { type: date, required: true, indexed: true } status: { type: choice, options: [{ key: active }, { key: ended }] } + rent: { type: money } ``` On the next request, the Leases list has a Rent column, the form has a money input, `rent` joins the API schema and the SDK's `Lease` type, and the changelog at [leaseclock.com/blog/changelog](https://leaseclock.com/blog/changelog) reads "Leases now track rent". Existing leases have no rent until someone sets it, unless the field has a `default`. ## Field types The type says what a value is, and every surface reads it. A `date` gets a date picker, a date column and range filters, and a `money` field gets a currency input and totals, with nothing else written. ### Text | Type | Holds | Settings | In JSON | | --- | --- | --- | --- | | `text` | One line, such as a unit's label | `max`, `pattern` | `"4B"` | | `longText` | Paragraphs, such as notes on a lease. `rich: true` allows formatting. | `max`, `pattern`, `rich` | `"Renter asked about…"` | | `email` | An email address, checked on save | `pattern` | `"dana.whitfield@example.com"` | | `phone` | A phone number, shown as a call link | `pattern` | `"+1 612 555 0142"` | | `url` | A web address, shown as a link | `pattern` | `"https://…"` | `pattern` is a regular expression every value must match: `pattern: '^[0-9]+[A-Z]$'` holds a unit label to the form 4B. ### Numbers | Type | Holds | Settings | In JSON | | --- | --- | --- | --- | | `number` | A count or a measure, such as bedrooms | `unit`, `decimals`, `range` | `2` | | `money` | An amount in a currency, such as monthly rent | `currency`, `decimals`, `range` | `1850`, which is $1,850 | | `percent` | A rate, such as a late fee | `decimals`, `range` | `5`, which is 5% | | `autoNumber` | A number assigned when the record is created, counting up | `start` | `1042` | Money is always an amount in the currency's main unit, the same way an [Offer](https://saas.dev/docs/offers.md) writes its price. A `money` field takes the currency of your Offer unless it sets its own, as `currency: EUR`. `range` sets the least and most a value may be: `range: { min: 0 }`. ### Dates | Type | Holds | In JSON | | --- | --- | --- | | `date` | A day, such as when a lease ends | `"2026-11-30"` | | `dateTime` | A moment, stored in UTC and shown in each viewer's time zone | `"2026-11-30T17:00:00.000Z"` | | `month` | A month, such as the month rent is due | `"2026-11"` | ### Choices | Type | Holds | Settings | In JSON | | --- | --- | --- | --- | | `yesNo` | Yes or no, such as whether pets are allowed | | `true` | | `choice` | One option from a list, such as a lease's status | `options` | `"active"` | | `choices` | Any of the options, such as a unit's amenities | `options` | `["parking", "laundry"]` | | `classification` | A standard code from NAICS, O\*NET or UNSPSC | `classification` | `"531110"` | Each option is a key with an optional label: `{ key: active, label: Active }`. Without a label, screens show the key with a capital letter. ### Links, people and files | Type | Holds | Settings | In JSON | | --- | --- | --- | --- | | `link` | Another record, such as a lease's Unit | `target`, `many`, `onDelete`, `reverseLabel` | `"4b"` | | `team` | People or agents in the customer's workspace, such as who manages a unit | `many`, `takes` | `"priya-shah"` | | `file` | An uploaded file, up to 25 MB, such as a signed lease | `accept`, `maxMb`, `many` | `{ "id", "name", "mime", "size" }` | ### Worked out | Type | Holds | Settings | In JSON | | --- | --- | --- | --- | | `lookup` | A field read through a link, such as a lease's building | `lookup` | the source field's value | | `rollup` | A count, sum, minimum, maximum or average over linked records | `rollup` | `3` | Lookups and rollups are read-only. Each stays current with the records it reads, and a request that sets one returns a 422. ### Lists Add `many: true` to hold a list. `phones: { type: phone, many: true }` keeps every number a renter gives, and `max: 3` caps it at three. ## Links A `link` field points at a record of another Noun. Lease's `unit` points at a Unit: ```yaml title="Noun/Lease.mdx" unit: { type: link, target: Unit, required: true } ``` The link reads both ways. The Lease form picks a Unit by its label, and each Unit page lists the leases that point at it, under the name `reverseLabel` sets. In the API, filter leases by unit with `GET /api/leases?unit=4b`. In the SDK, it is `$.Lease.list({ unit: '4b' })`. `onDelete` decides what happens to Unit 4B's leases when someone deletes Unit 4B: | `onDelete` | What happens | | --- | --- | | `block` | The delete returns a 409 while any lease points at the Unit. This is the default. | | `clear` | Each lease keeps its record and loses its Unit. | | `cascade` | The leases are deleted with the Unit. | Every delete is recorded in the record's history, so each of these can be undone. ### Read through a link A `lookup` shows a field of the linked record. A `rollup` adds up over the records that link in. Add a building to each lease and a lease count to each Unit: ```yaml title="Noun/Lease.mdx" building: { type: lookup, lookup: { via: unit, field: building } } ``` ```yaml title="Noun/Unit.mdx" leaseCount: { type: rollup, rollup: { via: Lease.unit, fn: count } } ``` `via` names the link: `unit` on Lease itself, or `Lease.unit` for the link that points in from Lease. A rollup's `fn` is `count`, `sum`, `min`, `max` or `average`, and every `fn` except `count` names the `field` it reads. ## Ids and titles Every record has an id, and the id is its address: Dana Whitfield's lease is `4b`, at `leaseclock.com/api/leases/4b`. Screens title a record by its `name`, `title` or `label` field, or else its first text field, so Unit 4B shows as "4B" wherever a lease links to it. ## Related - [Verbs](https://saas.dev/docs/verbs.md): what people and agents do to a Noun - [Offers](https://saas.dev/docs/offers.md): the plan your customers buy, and the limits it sets on each Noun - [Constraints](https://saas.dev/docs/constraints.md): every naming and field rule, and the surface that depends on it --- url: https://saas.dev/docs/verbs md: https://saas.dev/docs/verbs.md --- # Verbs A Verb is something people or agents do to a Noun. Lease has two: `renew` moves the end date forward, and `end` closes the lease. A Verb declares what it does, the input it takes and the states it moves between, and each Verb becomes a button, an endpoint, a command and a tool. ```yaml title="Noun/Lease.mdx" --- name: Lease fields: unit: { type: link, target: Unit, required: true } renter: { type: email, required: true } endsOn: { type: date, required: true, indexed: true } status: { type: choice, options: [{ key: active }, { key: ended }] } verbs: renew: does: Move the end date forward input: { endsOn: { says: the new end date, type: date, required: true } } from: [active] end: { does: End the lease, from: [active], to: ended } --- ``` ## Renew a lease Northgate Rentals runs its 24 units on [Leaseclock](https://leaseclock.com). Dana Whitfield's lease on Unit 4B ends on November 30, 2026, and Priya Shah, the leasing coordinator, renews it through November 30, 2027. She can do that from any surface. Each one runs the same `renew`, takes the same input, checks the same state and returns the same lease. **App** At [leaseclock.com/app/leases](https://leaseclock.com/app/leases), every active lease has a **Renew** button. Priya opens it on 4B, and the popover asks for one thing: **The new end date**. It shows Dana's current end date beside the field, so Priya sees the change before she presses **Renew**. The lease page then records who renewed it and when, with **Undo** beside the entry. **API** ```bash title="Terminal" curl -X POST https://leaseclock.com/api/leases/4b/renew \ -H "Authorization: Bearer $LEASECLOCK_TOKEN" \ -H "Idempotency-Key: renew-4b-2027-11-30" \ -H "Content-Type: application/json" \ -d '{ "endsOn": "2027-11-30" }' ``` ```json title="200 OK" { "$id": "https://leaseclock.com/api/leases/4b", "$type": "Lease", "unit": "4b", "renter": "dana.whitfield@example.com", "endsOn": "2027-11-30", "status": "active", "renewedBy": "priya@northgaterentals.com", "undo": "https://leaseclock.com/api/leases/4b/undo" } ``` Every Verb POST carries an `Idempotency-Key`, so a retry never renews twice. The [API reference](https://saas.dev/docs/api.md) lists every Verb's endpoint. **CLI** ```bash title="Terminal" $ npx leaseclock do Lease.renew 4b --endsOn 2027-11-30 Preview Lease 4B endsOn 2026-11-30 → 2027-11-30 Renew it? y ✓ Renewed lease 4B. It now ends 2027-11-30. Undo: npx leaseclock undo Lease.renew 4b ``` Add `--yes` to skip the question in a script. The [CLI](https://saas.dev/docs/cli.md) runs every Verb with `do`. **SDK** ```ts title="renew.ts" import { Leaseclock } from 'leaseclock' const $ = new Leaseclock({ token: process.env.LEASECLOCK_TOKEN }) const lease = await $.Lease.renew('4b', { endsOn: '2027-11-30' }) lease.endsOn // '2027-11-30' lease.status // 'active' ``` `renew` takes the lease's id and an input typed from the definition, so your editor flags a missing `endsOn` before the code runs. See the [SDK](https://saas.dev/docs/sdk.md). **MCP** Priya asks her agent: *"Renew Dana Whitfield's lease in 4B through November 30, 2027."* The agent finds the lease with `search`, then calls `do`: ```json title="do" { "verb": "Lease.renew", "id": "4b", "input": { "endsOn": "2027-11-30" } } ``` `do` runs any Verb, so a new Verb reaches every connected agent without a new tool. See [MCP](https://saas.dev/docs/mcp.md). ## Name The key under `verbs:` names the Verb. Write it in camelCase, starting lower-case, as something a person does: `renew`, `end`, `markPaid`. The name sets every place the Verb appears: | Surface | `renew` on Lease becomes | | --- | --- | | App | A **Renew** button on each lease (`markPaid` becomes **Mark paid**) | | API | `POST /api/leases/{id}/renew` | | CLI | `npx leaseclock do Lease.renew ` | | SDK | `$.Lease.renew(id, input)` | | MCP | `"verb": "Lease.renew"` in the `do` tool | | Docs | [leaseclock.com/docs/lease#renew](https://leaseclock.com/docs/lease#renew) | Push a new Verb and every surface has it on the next request: the button, the endpoint, the command, the method, the docs section and a changelog entry. ## What it does `does` is one sentence, written as an instruction to the person about to run the Verb. Leaseclock shows it as the hint on the Renew button, the summary at the top of the Verb's docs section, the description in the OpenAPI file and the help text in the CLI. Agents read it too, which is how they pick the right Verb. ```text title="Terminal" $ npx leaseclock do Lease.renew --help Move the end date forward Usage: npx leaseclock do Lease.renew --endsOn --endsOn date required The new end date Runs from: active ``` ## Input `input` lists what the Verb needs from whoever runs it. Each input is a field, with the same types and options as a Noun's [fields](https://saas.dev/docs/nouns.md), plus `says`, the label a person reads. | Line | What it generates | | --- | --- | | `endsOn:` | The input's key in the request body, the SDK type and the tool's input schema, and the `--endsOn` flag | | `says: the new end date` | The popover's label, **The new end date**, and the description in the docs and the CLI help | | `type: date` | A date picker in the popover, and a check that rejects anything that is not a date | | `required: true` | A required mark in the popover, and a 422 that names `endsOn` when it is missing | A Verb with no `input` runs on one click. `end` takes none, so its button asks only for confirmation. ## States `from` lists the states a Verb runs from, and `to` names the state it leaves the record in. Both name options of the Noun's `status` field. `end` runs from `active` and moves the lease to `ended`. `renew` has no `to`, so the lease stays `active` with its new end date. A Verb with no `from` runs in any state. On a lease in any other state, the Verb does not run, and every surface says why in the same words. Hannah Cho's lease on 5A ended on September 30, 2026, so its Renew and End buttons are disabled with the reason beside them, and the API answers 409: ```bash title="Terminal" $ npx leaseclock do Lease.end 5a ✗ Lease 5A is ended. end runs from active. ``` The states also drive the list: each `status` option gets a tab, and a lease's badge changes the moment a Verb moves it. ## Logic A Verb with no `fn` writes its input onto the record. `renew` declares `endsOn` as its input, so it sets `endsOn` and touches nothing else, and `end`, which takes no input, only moves the lease to `ended`. Most Verbs need nothing more. When a Verb needs logic of its own, export a function with the Verb's name after the front matter of the Noun's file. It receives the record and the input, and returns the fields to change. Throw to stop the Verb, and the message reaches whoever ran it, in the popover, the API error, the CLI or the agent's tool result. ```mdx title="Noun/Lease.mdx" export const renew = async (lease, { endsOn }) => { if (endsOn <= lease.endsOn) throw new Error('endsOn must be later') return { endsOn } } ``` The function runs inside SaaS.Dev with the same permissions, preview, undo and history as every other Verb. You maintain that one function. [Defaults and overrides](https://saas.dev/docs/overrides.md) covers the rest of what you can replace. ## Preview, undo and history Every Verb comes with these, from the first push: - **Preview.** Each surface shows the change before it commits. The popover shows the current and new values, the CLI asks before it runs, and on the API a GET on the Verb's URL returns the preview and changes nothing. - **Undo.** Each run saves a version of the record. The API returns an `undo` link, the CLI prints the undo command, and the lease page has an Undo button. - **History.** The lease page lists every run: which Verb, who ran it, when, and the values before and after. - **Permissions.** Roles decide who may run each Verb, and every surface checks the same rule. A customer's team sees only its own workspace's leases. - **Retries.** A POST with an `Idempotency-Key` that was already used returns the first result and runs nothing again. ## Rules A Verb's name is camelCase and starts lower-case. Every state in `from` and `to` is an option of the Noun's `status` field. Input keys follow the same rules as field keys. When a file breaks a rule, the push returns the line and the fix, so you or your agent correct it in one edit: ```text 422 Lease.mdx line 12: renew runs from "Active", which is not a status of Lease. Use "active". ``` [Constraints](https://saas.dev/docs/constraints.md) explains why each rule exists, and [Errors](https://saas.dev/docs/errors.md) lists every code with its fix. --- url: https://saas.dev/docs/offers md: https://saas.dev/docs/offers.md --- # Offers An Offer is a plan your customers buy. Leaseclock Pro costs $29 a month and allows 200 Units. The same file sets the pricing page, the Stripe product and price, the limit the API enforces and the upgrade prompt in the app. ## The file Each Offer is one file in your product's `Offer` folder. This is Leaseclock Pro, as Leaseclock pushes it: ```yaml title="Offer/pro.mdx" --- name: Leaseclock Pro price: { amount: 29, currency: USD, unit: month } limits: { Unit: 200 } personas: Property manager, Leasing coordinator status: Live --- ``` Push it with [`npx saas.dev push`](https://saas.dev/docs/saas-dev-cli.md), and Leaseclock is on sale. ## What it sets | Where | What Leaseclock Pro puts there | | --- | --- | | [leaseclock.com/pricing](https://leaseclock.com/pricing) | A card reading "Leaseclock Pro, $29 a month", "Up to 200 units" and "For property managers and leasing coordinators", with a Subscribe button | | Your Stripe account | A product named Leaseclock Pro with a monthly price of $29.00 USD | | Stripe Checkout | "Subscribe to Leaseclock Pro, $29.00 per month", in Leaseclock's teal | | The API | A `402 limit-reached` error for any Unit past a customer's 200th | | The app | "24 of 200 Units" on Northgate Rentals' Billing screen, and the upgrade prompt when a customer reaches the limit | | Docs and `llms.txt` | The plan, its price, its limits and who it is for, so a customer's agent can compare plans for its user | The pricing page, checkout and Stripe read the price from one line, so they always agree. ## Keys | Key | In Leaseclock Pro | What it does | | --- | --- | --- | | `name` | `Leaseclock Pro` | The plan's name on the pricing page, at checkout, in Stripe and on every invoice | | `price.amount` | `29` | The price in whole units of the currency. `29` charges $29.00, and `29.50` charges $29.50 | | `price.currency` | `USD` | Any three-letter currency code Stripe accepts | | `price.unit` | `month` | The billing period, `month` or `year`. Stripe renews the subscription once per period | | `limits` | `{ Unit: 200 }` | The most records of each named Noun one customer may hold. A Noun left out has no limit | | `personas` | `Property manager, Leasing coordinator` | Who the plan is for, shown on the pricing card and in your docs | | `status` | `Live` | `Draft` sells in Test only, `Live` sells in Test and Live, and `Retired` stops new sales | | `$id` | `leaseclock.com/offers/pro` | The Offer's address. It ends in the file name, `pro` | ## Limits A limit counts the records one customer holds in its workspace. Northgate Rentals manages 24 apartments, so it holds 24 Units and has room for 176 more on Leaseclock Pro. A Unit that is deleted stops counting. The API enforces each limit, so the app, the CLI, the SDK and your customers' agents all stop at the same place. Here is the request that would add a customer's 201st Unit: ```sh title="Terminal" curl -X POST https://leaseclock.com/api/units \ -H "Authorization: Bearer $LEASECLOCK_TOKEN" \ -H "Content-Type: application/json" \ -d '{ "label": "9A", "building": "Birch House" }' ``` ```json title="402 Payment Required" { "error": { "code": "limit-reached", "message": "Leaseclock Pro includes 200 Units, and this workspace holds 200.", "noun": "Unit", "limit": 200, "offer": "leaseclock.com/offers/pro", "docs": "https://leaseclock.com/docs/errors#limit_reached" } } ``` The request saves nothing. An import that would pass the limit is rejected whole and saves no records. Your customers read about the error on your own docs at `leaseclock.com/docs/errors`, and you read about it in [Errors](https://saas.dev/docs/errors.md#limit_reached). ## Add a free plan An Offer with an `amount` of `0` is a free plan. To let property managers start Leaseclock without a card, add a second file: ```yaml title="Offer/starter.mdx" --- name: Leaseclock Starter price: { amount: 0, currency: USD, unit: month } limits: { Unit: 10 } personas: Independent landlord status: Live --- ``` New customers start on Leaseclock Starter without visiting checkout. The pricing page shows both cards, cheapest first. ## The upgrade prompt When a customer reaches a limit, the button that would pass it opens the upgrade prompt in place of the form. The prompt states the limit, how many records the customer holds, and the smallest Live Offer that allows more, with an Upgrade button that opens Stripe Checkout. The new limit applies the moment Stripe confirms the payment. A Leaseclock Starter customer who adds an eleventh Unit sees: > **Leaseclock Starter includes 10 Units** > > Upgrade to Leaseclock Pro for up to 200 Units, $29 a month. > > **Upgrade** · Not now When no Live Offer allows more, the prompt states the limit and the count, with no Upgrade button. ## Change an Offer Edit the file and push it again. To raise Leaseclock Pro to $35 a month: ```diff title="Offer/pro.mdx" - price: { amount: 29, currency: USD, unit: month } + price: { amount: 35, currency: USD, unit: month } ``` The pricing page and the next checkout show $35. SaaS.Dev adds a new $35.00 price to the Stripe product, and every customer already subscribed keeps the $29.00 price they bought. A new limit applies to every customer on the Offer on the next request. Raise `Unit` to 250 and each Pro customer can add 50 more. Lower it, and a customer over the new limit keeps every record it has and adds no more until it is under the limit. A new `name` renames the Stripe product, the pricing card and the next invoice. The `$id` stays the same, so subscriptions carry over. To stop selling an Offer, set `status: Retired`. It leaves the pricing page, the upgrade prompt and checkout, and every current subscription renews as before. ## Questions ### Can a plan have a monthly and a yearly price? Yes. Write `price` as a list: `price: [{ amount: 29, currency: USD, unit: month }, { amount: 290, currency: USD, unit: year }]`. The pricing page gets a Monthly and Yearly switch, and Stripe gets one price for each. ### What can a customer do before it subscribes? With a free Offer Live, it starts on that plan. Without one, every limit is zero until it subscribes, so a new customer's first New Unit opens the prompt for Leaseclock Pro. Nouns with no limit stay open. ### Can I give a discount? Yes. Create a promotion code in your Stripe dashboard. Checkout accepts it, and the Offer stays as written. ### Does SaaS.Dev take a share of what customers pay? No. Customers pay you through your own Stripe account, and Stripe charges its standard card rate. See [Stripe](https://saas.dev/docs/stripe.md) for checkout, subscriptions and payouts. ### Which Nouns can carry a limit? Any Noun in your product. `limits: { Unit: 200, Lease: 400 }` limits both. A [Noun](https://saas.dev/docs/nouns.md) left out of `limits` has no cap. --- url: https://saas.dev/docs/app md: https://saas.dev/docs/app.md --- # App Every Noun gets a list, a detail page and a form, and every Verb gets a button. Your team works in the admin. Your customers, each in a workspace of their own, use the same screens limited to their own records. Each line of Leaseclock's definition maps to what it does on screen. ## In short - **Your customers** sign in at `leaseclock.com/app`. Each customer company is a workspace, so Northgate Rentals sees its own units and leases and nobody else's. - **Your team** signs in at `leaseclock.com/admin` and gets the same screens across every workspace, plus the Billing and Settings for Leaseclock itself. - **Every Noun** gets a list, a record page, a New form and an Edit form. **Every Verb** gets a button, and a popover when it takes input. - **Sign-in, roles, preview, undo and history** come with every screen. - **Permissions live in the API**, so a customer can't reach another customer's records from any screen, script or agent. - **The look** follows your theme, and you can replace any field, view or screen with your own React component. ## Leaseclock's Leases screen Priya Shah, the leasing coordinator at Northgate Rentals, renews Dana Whitfield's lease on Unit 4B. [Open the Leases screen on leaseclock.com](https://leaseclock.com/app/leases). [Leaseclock's Leases list for Northgate Rentals, on its Active tab, with 4B renewed to Nov 30, 2027 and Undo](https://leaseclock.com/app/leases) Lease's definition file shapes most of that screen: ```yaml title="Noun/Lease.mdx" --- name: Lease fields: unit: { type: link, target: Unit, required: true } renter: { type: email, required: true } endsOn: { type: date, required: true, indexed: true } status: { type: choice, options: [{ key: active }, { key: ended }] } verbs: renew: does: Move the end date forward input: { endsOn: { says: the new end date, type: date, required: true } } from: [active] end: { does: End the lease, from: [active], to: ended } --- ``` ## What each line puts on screen | Definition line | On screen | | --- | --- | | `name: Lease` | Leases in the sidebar, with a list, a record page, a New form and an Edit form. The search box reads "Search leases" and the button reads "New lease". | | `unit: { type: link, target: Unit }` | The Unit column, showing 4B and its building, Alder Court. A Unit picker in both forms. Unit 4B's own page lists every lease on it. | | `renter: { type: email }` | The Renter column, as mail links. An input that rejects anything that isn't an email address. | | `endsOn: { type: date }` | The Ends on column, printed Nov 30, 2026. A date picker in both forms. | | `indexed: true` | The list sorted by end date, earliest first, with an Ends before filter. | | `status: { type: choice }` | A badge on every row, and the All, Active and Ended tabs. The forms have no status box, because Verbs move the status. | | `required: true` | A red mark beside the label, and an error when the field is left empty. | | `does: Move the end date forward` | The Renew popover's subtitle, and the button's tooltip. | | `says: the new end date` | The Renew popover's one field, labelled "The new end date". | | `from: [active]` | Renew and End on every active lease. On an ended lease both are disabled, with the reason shown. | | `end: { to: ended }` | An End button on each lease, which moves its badge to Ended. | | `name: Leaseclock Pro` in `Offer/pro.mdx` | The plan under the workspace name, and the Billing screen. | | `limits: { Unit: 200 }` in `Offer/pro.mdx` | At 200 units, New unit is disabled and the limit is shown. | Each record is titled by its `name`, `title` or `label` field, or else by its first text field. A Lease has none of those, so it takes its Unit's label: the popover reads "Renew lease 4B". ## Try it [test.leaseclock.com/app/leases](https://test.leaseclock.com/app/leases) opens a fresh Northgate Rentals workspace for every visitor, with no sign-in. Press **Renew** on 4B, pick a new end date and press Renew: 4B moves to its new date, in place, and the toast offers Undo. Press **Undo**, and the old date is back, with both changes in 4B's history. ## The record page `leaseclock.com/app/leases/4b` is Lease 4B's page. It shows every field, links the Unit to Unit 4B's page, and puts Renew, End and Edit in the header. Its history lists every change with who made it and when: | When | Who | Change | | --- | --- | --- | | Oct 20, 2026 | Priya Shah | Renewed. Ends on Nov 30, 2026 → Nov 30, 2027 | | Nov 30, 2025 | Priya Shah | Created, for dana.whitfield@example.com | Every entry has an Undo button. Undo is itself recorded, and a deleted record keeps its history, so nothing is lost. ## Preview before anything changes A Verb's popover shows the record as it stands before the change. Renew lease 4B shows Dana Whitfield and the current end date, Nov 30, 2026, beside the new one. Edit forms show each changed field before Save. Nothing is written until the person presses the Verb's button. ## Workspaces A property manager who signs up on leaseclock.com gets a workspace named for their company, and buys Leaseclock Pro from its Billing screen. Every unit and lease they create belongs to that workspace, and the plan's limits count per workspace. In the admin, your team picks a workspace from the switcher, or sees every workspace's leases in one list with a Workspace column. Open any workspace to see exactly what its people see. Every change you make there is recorded under your own name. ## Sign-in and roles Customers sign up and sign in on leaseclock.com, under Leaseclock's name and theme, and every sign-in email comes from Leaseclock. Each workspace's owner invites people from the Team screen and gives each one a role: | Role | What they can do in the workspace | | --- | --- | | Owner | Everything, including the plan, billing and closing the workspace | | Admin | Invite people, set roles and change settings, plus everything a member can do | | Member | Create and edit records, and run every Verb | | Viewer | Read records and their history | Northgate's owner invites Priya Shah as a member, so she renews leases but never sees the card on file. A Verb someone's role can't run is hidden from them. A Verb the record's status blocks stays visible, disabled, with the reason shown. Your own team uses the same roles in the admin, across every workspace. ## Links into the app Every screen has its own address, built from the Noun's name, so a reminder email or a message to a teammate opens the exact record: | Address | Opens | | --- | --- | | `leaseclock.com/app/leases` | The Leases list | | `leaseclock.com/app/leases?status=active` | The Active tab | | `leaseclock.com/app/leases/new` | The New lease form | | `leaseclock.com/app/leases/4b` | Lease 4B's record page | | `leaseclock.com/app/leases/4b/edit` | Lease 4B's Edit form | | `leaseclock.com/app/leases/4b/renew` | Lease 4B with the Renew popover open | Every button calls Leaseclock's API with the signed-in person's permissions. A script or an agent makes the same call and gets the same result, recorded in the same history: **API** ```bash curl -X POST https://leaseclock.com/api/leases/4b/renew \ -H "Authorization: Bearer $LEASECLOCK_TOKEN" \ -H "Idempotency-Key: renew-4b-2027-11-30" \ -H "Content-Type: application/json" \ -d '{ "endsOn": "2027-11-30" }' ``` **CLI** ```bash npx leaseclock do Lease.renew 4b --endsOn 2027-11-30 ``` The [API](https://saas.dev/docs/api.md), [CLI](https://saas.dev/docs/cli.md) and [SDK](https://saas.dev/docs/sdk.md) pages cover every operation. ## Change what a screen shows Three lines of `theme.json` set Leaseclock's font and teal on every screen, page and email. To go further, replace one field, one view or a whole screen with your own React component. This view replaces Lease's record page and keeps the generated Renew: ```tsx title="views/Lease.tsx" export default function LeaseDetail({ lease, $ }) { return } ``` You maintain that one page. Sign-in, permissions, history and the API stay generated. [Defaults and overrides](https://saas.dev/docs/overrides.md) lists every default and how to change it. ## Questions ### Can a customer see another customer's records? No. Every record belongs to one workspace, and the API checks the workspace on every request. A screen, a script and an agent all go through the same check. ### Which columns does a list show? Up to seven of the Noun's fields, in the order its file lists them. Lease lists `unit` first, so Unit is the first column. Reorder the fields to reorder the columns, or replace the list view with your own component. ### What happens on screen when I add a field? Add `rent: { type: money }` to Lease and push. The next request shows a Rent column, a Rent input in both forms and the rent on each record page. There is nothing to migrate or redeploy. ### What does an empty list show? "No leases yet" and a New lease button. On a tab or filter with no matches, it names the filter and offers to clear it. ### Does the app work on a phone? Yes. On a phone the sidebar folds into a top bar with the screen's name and a menu, the workspace sits in a chip under it with New beside it, each list row takes two lines with its Verb on the right, popovers open as sheets from the bottom, and the toast pins to the bottom edge. ### Can my team's agent read billing? Yes. Everything on the admin's Billing screen is a Noun your team's agent can search, as [MCP](https://saas.dev/docs/mcp.md#your-teams-agent) shows. ### Can my team see what a customer sees? Yes. From the admin, open the customer's workspace. You see their screens with their records, and every change you make is recorded under your name. --- url: https://saas.dev/docs/api md: https://saas.dev/docs/api.md --- # API Leaseclock's API is at `leaseclock.com/api`. Every Noun has list, get, create and update operations, and every Verb has its own POST. Every response uses the same JSON shape, and every error links to the page that explains how to fix it. ## In short [A renew request to the Leaseclock API and its 200 response](https://leaseclock.com/api) - The base URL is `https://leaseclock.com/api`. Paths use each Noun's plural name: `/api/leases`, `/api/units`. - Requests and responses are JSON. A record is the same object in every response, from a list, a get, a write or a Verb. - Every request carries a key in the `Authorization` header. A key acts with the role and the workspace of the person who created it. - Every write is recorded as a version, returns an `undo` link and appears in the record's history. - The OpenAPI file is at `https://leaseclock.com/openapi.json`, and every operation has a page at `leaseclock.com/docs`, so your customers and their tools read the same reference you do. ## Leaseclock over HTTP Priya Shah, the leasing coordinator at Northgate Rentals, renews Dana Whitfield's lease on Unit 4B for another year. `renew` is a Verb in `Lease.mdx`, so it is a POST on the lease. **API** ```bash title="Terminal" curl https://leaseclock.com/api/leases/4b/renew \ -H "Authorization: Bearer $LEASECLOCK_TOKEN" \ -H "Content-Type: application/json" \ -H "Idempotency-Key: $(uuidgen)" \ -d '{ "endsOn": "2027-11-30" }' ``` **CLI** ```bash title="Terminal" npx leaseclock do Lease.renew 4b --endsOn 2027-11-30 ``` **SDK** ```ts title="renew.ts" import { Leaseclock } from 'leaseclock' const $ = new Leaseclock({ token: process.env.LEASECLOCK_TOKEN }) await $.Lease.renew('4b', { endsOn: '2027-11-30' }) ``` ```json title="200 OK" { "$id": "https://leaseclock.com/api/leases/4b", "$type": "Lease", "unit": "4b", "renter": "dana.whitfield@example.com", "endsOn": "2027-11-30", "status": "active", "renewedBy": "priya@northgaterentals.com", "undo": "https://leaseclock.com/api/leases/4b/undo" } ``` The SDK and the CLI send this same request and get this same record back. So do the app's Renew button and an agent calling `do` over [MCP](https://saas.dev/docs/mcp.md). ## Operations `name: Lease` gives Lease its list, get, create and update operations at `/api/leases`. Each Verb under `verbs:` adds a POST, and a GET on the same URL previews it. | Operation | Request | | --- | --- | | [List leases](#list-records) | `GET /api/leases` | | [Get a lease](#get-a-record) | `GET /api/leases/{id}` | | [Create a lease](#create-a-record) | `POST /api/leases` | | [Update a lease](#update-a-record) | `PATCH /api/leases/{id}` | | [Renew a lease](#run-a-verb) | `POST /api/leases/{id}/renew` | | [End a lease](#run-a-verb) | `POST /api/leases/{id}/end` | | [Preview a renewal](#preview-a-verb) | `GET /api/leases/{id}/renew` | | [Undo the last write](#undo-and-history) | `POST /api/leases/{id}/undo` | | [List every version](#undo-and-history) | `GET /api/leases/{id}/history` | Unit has the same operations at `/api/units`, without Verbs of its own. Add a Noun or a Verb to the definition and push it, and its operations answer on the next request. ## List records A list returns the records the key can see, sorted by the first indexed field. Lease sorts by `endsOn`, so the leases ending soonest come first. ```bash title="Terminal" curl -G https://leaseclock.com/api/leases \ -H "Authorization: Bearer $LEASECLOCK_TOKEN" \ -d status=active \ -d endsOn.before=2027-01-01 ``` ```json title="200 OK" { "$id": "https://leaseclock.com/api/leases?status=active&endsOn.before=2027-01-01", "items": [ { "$id": "https://leaseclock.com/api/leases/4b", "$type": "Lease", "unit": "4b", "renter": "dana.whitfield@example.com", "endsOn": "2026-11-30", "status": "active" }, { "$id": "https://leaseclock.com/api/leases/2a", "$type": "Lease", "unit": "2a", "renter": "marcus.bell@example.com", "endsOn": "2026-12-15", "status": "active" } ], "next": null } ``` | Parameter | What it does | | --- | --- | | Any field, as `status=active` or `unit=4b` | Returns records whose field has that value. A choice field accepts only its options. | | `endsOn.before`, `endsOn.after` | Returns records before or after a date. Every indexed field takes both. | | `sort` | Sorts by an indexed field, as `sort=endsOn` or `sort=-endsOn` for newest first | | `expand` | Returns a linked record in place of its id, as `expand=unit` | | `limit` | Returns fewer records a page. A page holds up to 200. | | `after` | Starts the page after a record. `next` holds the full URL of the next page, and is `null` on the last one. | ## Get a record ```bash title="Terminal" curl "https://leaseclock.com/api/leases/4b?expand=unit" \ -H "Authorization: Bearer $LEASECLOCK_TOKEN" ``` ```json title="200 OK" { "$id": "https://leaseclock.com/api/leases/4b", "$type": "Lease", "unit": { "$id": "https://leaseclock.com/api/units/4b", "$type": "Unit", "label": "4B", "building": "Alder Court" }, "renter": "dana.whitfield@example.com", "endsOn": "2027-11-30", "status": "active" } ``` A record's id comes from its name or label, so Unit 4B is `4b`. Ids are stable: a later change to the label keeps the id. ## Create a record ```bash title="Terminal" curl https://leaseclock.com/api/units \ -H "Authorization: Bearer $LEASECLOCK_TOKEN" \ -H "Content-Type: application/json" \ -d '{ "label": "6E", "building": "Birch House" }' ``` ```json title="201 Created" { "$id": "https://leaseclock.com/api/units/6e", "$type": "Unit", "label": "6E", "building": "Birch House", "undo": "https://leaseclock.com/api/units/6e/undo" } ``` Required fields must be present, and every value must match its field's type. A missing `label` returns `422 invalid_field` naming `label`. ## Update a record `PATCH` changes only the fields it sends. ```bash title="Terminal" curl -X PATCH https://leaseclock.com/api/leases/2a \ -H "Authorization: Bearer $LEASECLOCK_TOKEN" \ -H "Content-Type: application/json" \ -d '{ "renter": "marcus.j.bell@example.com" }' ``` A field a Verb moves cannot be patched. Lease's `status` changes only through `end`, so a PATCH that sends `status` returns `422 field_set_by_verb`, with `POST /api/leases/2a/end` as the fix. ## Run a Verb A Verb is a POST on the record, with the Verb's input as the body. `renew` declares one input, `endsOn`, and runs only `from: [active]`. `end` takes no input. ```bash title="Terminal" curl -X POST https://leaseclock.com/api/leases/7c/end \ -H "Authorization: Bearer $LEASECLOCK_TOKEN" \ -H "Idempotency-Key: $(uuidgen)" ``` Every Verb POST requires an `Idempotency-Key` header. A retry with the same key returns the first response and runs nothing a second time, so a dropped connection never renews a lease twice. ## Preview a Verb A GET on a Verb's URL returns the record as the Verb would leave it, and changes nothing. The input goes in the query string. ```bash title="Terminal" curl -G https://leaseclock.com/api/leases/4b/renew \ -H "Authorization: Bearer $LEASECLOCK_TOKEN" \ -d endsOn=2027-11-30 ``` ```json title="200 OK" { "$id": "https://leaseclock.com/api/leases/4b", "$type": "Lease", "unit": "4b", "renter": "dana.whitfield@example.com", "endsOn": "2027-11-30", "status": "active", "preview": true } ``` A preview checks everything the POST checks: the key's role, the lease's state and the input. A preview that would fail returns the same error the POST would. ## Undo and history Every write returns an `undo` link. A POST to it puts the record back as it was before that write, and records the undo as a version of its own. ```bash title="Terminal" curl -X POST https://leaseclock.com/api/leases/4b/undo \ -H "Authorization: Bearer $LEASECLOCK_TOKEN" \ -H "Idempotency-Key: $(uuidgen)" ``` `history` lists every version of a record, newest first, with who made the change and from where. ```json title="GET /api/leases/4b/history" { "$id": "https://leaseclock.com/api/leases/4b/history", "items": [ { "version": 2, "at": "2026-10-21T14:02:11Z", "by": "priya@northgaterentals.com", "via": "API", "verb": "renew", "changes": { "endsOn": ["2026-11-30", "2027-11-30"] } }, { "version": 1, "at": "2026-08-21T09:40:03Z", "by": "priya@northgaterentals.com", "via": "App", "verb": "create" } ], "next": null } ``` ## Keys Your customers create keys in the Leaseclock app, under **Settings → API keys**. The key is shown once. Scripts read it from the environment as `LEASECLOCK_TOKEN`, and it can be revoked on the same screen at any time. ```bash title="Terminal" export LEASECLOCK_TOKEN=… # from Settings → API keys curl https://leaseclock.com/api/leases -H "Authorization: Bearer $LEASECLOCK_TOKEN" ``` Every key is an id.org.ai token. It acts with the role of the person who created it: | Role | Can call | | --- | --- | | Viewer | List, get, preview and history | | Member | Everything a viewer can, plus create, update, every Verb and undo | | Admin | Everything a member can, plus creating and revoking keys for the workspace | | Owner | Everything an admin can, plus the workspace's plan and billing | A key reaches one workspace. A key from Northgate Rentals lists Northgate's leases and nobody else's, whatever the path or filter asks for. Your own team signs in as Leaseclock's admins and names a customer with the `Workspace` header, as in `Workspace: northgate-rentals`. ## Test and Live Every workspace has a Test copy with its own records, and checkout in Stripe's test mode. The key decides which copy a call reaches: a Test key reaches Test, and a Live key reaches Live, on the API, the SDK, the CLI and MCP alike. Switch the app to Test, then create the key under **Settings → API keys**. ```bash title="Terminal" export LEASECLOCK_TOKEN=… # a Test key from Settings → API keys curl https://leaseclock.com/api/leases -H "Authorization: Bearer $LEASECLOCK_TOKEN" ``` The web surfaces of the Test copy answer at `test.leaseclock.com`, and at `my-product.test.saas.dev` before a product has a domain of its own. Test and Live share the definition and every operation, so a script that passes in Test runs unchanged in Live with a Live key. ## Limits | Limit | Value | When it is reached | | --- | --- | --- | | Requests | 100 a second per key in Live, 25 in Test | `429 rate_limited`, with `Retry-After` in seconds | | Records a page | 200 | A `limit` over 200 returns 200 | | Request body | 1 MB | `413 body_too_large` | | The Offer's limits | Leaseclock Pro: 200 Units | `402 limit_reached`, with the link to upgrade | Every response carries `RateLimit-Limit`, `RateLimit-Remaining` and `RateLimit-Reset`, so a script can slow down before it is stopped. The Offer's limits come from `Offer/pro.mdx`. `limits: { Unit: 200 }` is the line the API checks before the 201st Unit is created, and the same line sets the pricing page and the upgrade prompt in the app. ## Responses Every response is one of three shapes. **A record** has its `$id`, its `$type` and its fields. A write adds `undo`, and a Verb adds who ran it. **A list** has its `$id`, the records in `items`, and `next`. **An error** has a code, the field it refers to, a message, the fix and the page that explains it: ```json title="409 Conflict" { "error": { "code": "wrong_state", "message": "Lease 5a is ended, and renew runs only on an active lease.", "fix": "Renew an active lease, or create a new lease for Unit 5A.", "docs": "https://leaseclock.com/docs/errors#wrong_state" } } ``` Field values in JSON follow their type in the definition: | Field type | In JSON | | --- | --- | | `text`, `email` | A string | | `date` | A string, as `2027-11-30` | | `choice` | The option's key, as `"active"` | | `link` | The linked record's id, as `"4b"`, or the whole record with `expand` | | `money` | A number in the currency's units, as `1850` | ## Errors | Status | Code | When | | --- | --- | --- | | 400 | [`invalid_json`](https://saas.dev/docs/errors.md#invalid_json) | The body is not valid JSON | | 400 | [`idempotency_key_required`](https://saas.dev/docs/errors.md#idempotency_key_required) | A Verb POST has no Idempotency-Key | | 401 | [`invalid_key`](https://saas.dev/docs/errors.md#invalid_key) | The key is missing, revoked or mistyped | | 402 | [`limit_reached`](https://saas.dev/docs/errors.md#limit_reached) | The write would pass a limit of the customer's Offer | | 403 | [`not_allowed`](https://saas.dev/docs/errors.md#not_allowed) | The key's role cannot make this call | | 404 | [`not_found`](https://saas.dev/docs/errors.md#not_found) | No record with that id in the key's workspace | | 409 | [`wrong_state`](https://saas.dev/docs/errors.md#wrong_state) | The Verb does not run from the record's current state | | 409 | [`idempotency_key_reused`](https://saas.dev/docs/errors.md#idempotency_key_reused) | The Idempotency-Key was used for a different request | | 413 | [`body_too_large`](https://saas.dev/docs/errors.md#body_too_large) | The body is over 1 MB | | 422 | [`invalid_field`](https://saas.dev/docs/errors.md#invalid_field) | A field is missing, unknown or the wrong type | | 422 | [`field_set_by_verb`](https://saas.dev/docs/errors.md#field_set_by_verb) | A PATCH sends a field that only a Verb changes | | 422 | [`verb_stopped`](https://saas.dev/docs/errors.md#verb_stopped) | The Verb's own function threw | | 429 | [`rate_limited`](https://saas.dev/docs/errors.md#rate_limited) | The key sent more requests a second than its limit | | 500 | [`server_error`](https://saas.dev/docs/errors.md#server_error) | Something failed inside SaaS.Dev | Each code's `docs` link opens Leaseclock's own page for it at `leaseclock.com/docs/errors`, written in Leaseclock's names, so your customers fix the call without writing to you. ## Questions ### Does my product's API mention SaaS.Dev No. The host, the paths, the headers, the keys and every error message use Leaseclock's names and Leaseclock's domain. A property manager calling the API sees Leaseclock. ### What happens to my customers' scripts when I change the definition Adding a field, a Noun or a Verb adds to the API, and every existing call keeps working. Renaming or removing one changes paths that scripts may call, so the push shows each changed path before anything goes Live, and the [changelog](https://saas.dev/docs/blog.md) lists the change for your customers. ### Can a record be deleted Yes. `DELETE /api/units/6e` removes the unit from every list and records the removal as a version, so its `undo` link brings it back with its history. ### How do I call SaaS.Dev itself `api.saas.dev` has the same shape for your own work: your products, their definitions and their versions. It reads `$SAAS_DEV_TOKEN`, which [`npx saas.dev login`](https://saas.dev/docs/saas-dev-cli.md) stores. See the [Quickstart](https://saas.dev/docs/quickstart.md). ### Can I change what an operation does Yes. A Verb's logic is a function you write in its definition, and it runs inside the same checks, preview and undo. Any operation can be replaced with your own code. See [Defaults and overrides](https://saas.dev/docs/overrides.md). --- url: https://saas.dev/docs/cli md: https://saas.dev/docs/cli.md --- # CLI `npx leaseclock show leases` lists leases, and `npx leaseclock do Lease.renew 4b --endsOn 2027-11-30` renews one. Your team and your customers' scripts use the same commands, with the same permissions as the app. ## In short [A terminal running npx leaseclock show and do](https://leaseclock.com/docs/cli) - The CLI ships in the `leaseclock` package on npm, under your product's name. `npx leaseclock` runs it with nothing to install. - `show` reads records. The words after it are the API path, so `show leases 4b` returns what `GET leaseclock.com/api/leases/4b` returns. - `do` runs a Verb. Every Verb in your definition is a command, and every Noun also has `create` and `update`. - Each `do` previews the change and asks before it runs, and `undo` reverses it. - A token carries its owner's role and workspace, so the CLI can do exactly what that person can do in the app. ## Leaseclock in a terminal Priya Shah, the leasing coordinator at Northgate Rentals, runs [Leaseclock](https://leaseclock.com) from her terminal. She checks which leases are active and renews Dana Whitfield's lease on Unit 4B for another year. ```bash title="Terminal" $ npx leaseclock show leases --status active UNIT RENTER ENDS ON 4B dana.whitfield@example.com 2026-11-30 2A marcus.bell@example.com 2026-12-15 7C lena.okafor@example.com 2027-01-31 3D tomas.rivera@example.com 2027-03-31 1F owen.price@example.com 2027-06-30 $ npx leaseclock do Lease.renew 4b --endsOn 2027-11-30 Preview Lease 4B endsOn 2026-11-30 → 2027-11-30 Renew it? y ✓ Renewed lease 4B. It now ends 2027-11-30. Undo: npx leaseclock undo Lease.renew 4b ``` The leases come back sorted by end date, because `endsOn` is indexed in `Lease.mdx`. The same renewal is a button in the app, a `POST` in the [API](https://saas.dev/docs/api.md) and a `do` call in [MCP](https://saas.dev/docs/mcp.md), and it lands in the lease's history the same way from each. ## What each definition line makes Each command, flag and help line comes from a line of Leaseclock's definition. | In `Lease.mdx` | In the CLI | | --- | --- | | `name: Lease` | `show leases`, `show leases 4b` and `do Lease.…` | | `endsOn: { type: date, required: true, indexed: true }` | Lists sorted by end date, and the `--endsOn.before` and `--endsOn.after` filters | | `status: { type: choice, options: [{ key: active }, { key: ended }] }` | `--status active`, with any other value rejected before the request is sent | | `input: { endsOn: { says: the new end date, type: date, required: true } }` | `do Lease.renew --endsOn `, which stops when `--endsOn` is missing | | `from: [active]` | `renew` runs only on an active lease | | `does: Move the end date forward` | The first line of `help Lease.renew`, and `says` gives the flag its description | ```bash title="Terminal" $ npx leaseclock help Lease.renew Lease.renew: Move the end date forward npx leaseclock do Lease.renew --endsOn --endsOn the new end date (required) Runs on: active ``` ## Sign in ```bash title="Terminal" npx leaseclock login ``` `login` opens Leaseclock's sign-in in the browser and stores the token on the machine. Every later command uses it. Scripts and CI read the token from `LEASECLOCK_TOKEN` instead. Create one in the Leaseclock app under **Settings → API keys**. It carries the role of the person who created it, and it can be revoked there at any time. ## Read records `show` takes a Noun's plural name, and optionally one record's id. ```bash title="Terminal" $ npx leaseclock show leases 4b Lease 4B unit 4B · Alder Court renter dana.whitfield@example.com endsOn 2027-11-30 status active Verbs renew, end ``` Any field filters by its value: `--status active`, `--unit 4b`. An indexed field also takes `.before` and `.after`: ```bash title="Terminal" npx leaseclock show leases --status active --endsOn.before 2027-01-01 ``` A list returns up to 200 records. Pass `--limit` for fewer, or `--all` to page through every match. ## Run a Verb `do` takes `Noun.verb`, the record's id and the Verb's input as flags. ```bash title="Terminal" npx leaseclock do Lease.renew 4b --endsOn 2027-11-30 npx leaseclock do Lease.end 7c npx leaseclock do Unit.create --label 6E --building "Birch House" npx leaseclock do Lease.update 2a --renter m.bell@example.com ``` Before anything changes, `do` prints the preview and asks to confirm. `--preview` prints it and stops. When a Verb cannot run, the CLI says why, links the fix and exits non-zero: ```bash title="Terminal" $ npx leaseclock do Lease.renew 5a --endsOn 2027-09-30 ✗ Lease 5A is ended, and renew runs only on an active lease. See https://leaseclock.com/docs/lease#renew ``` ## Undo and history Every `do` is recorded as a version of the record. `undo` reverses the last run of a Verb, and `history` lists every change with who made it and from where. ```bash title="Terminal" $ npx leaseclock history leases 4b 2026-10-25 14:02 renew endsOn 2026-11-30 → 2027-11-30 priya@northgaterentals.com CLI 2025-11-12 09:40 create priya@northgaterentals.com App $ npx leaseclock undo Lease.renew 4b Preview Lease 4B endsOn 2027-11-30 → 2026-11-30 Undo the renewal? y ✓ Lease 4B ends 2026-11-30 again. ``` ## Use it in scripts `--json` prints each record on its own line, as the same JSON the API returns. `--yes` confirms without a prompt. Without a terminal to confirm in, `do` prints the preview and stops, so a script changes nothing unless it passes `--yes`. ```bash title="renewals-due.sh" # Who to call this week: every active lease ending before the new year. npx leaseclock show leases --status active --endsOn.before 2027-01-01 --json \ | jq -r '.renter' ``` ```text title="Output" dana.whitfield@example.com marcus.bell@example.com ``` In CI, pass the token as a secret: ```yaml title=".github/workflows/renewals.yml" - run: npx leaseclock show leases --status active --endsOn.before 2027-01-01 --json env: LEASECLOCK_TOKEN: ${{ secrets.LEASECLOCK_TOKEN }} ``` ## Who can run what The CLI checks the same roles as the app, on every command. - **Your customers** see only their own workspace. A token from Northgate Rentals lists Northgate's leases and nobody else's. - **A viewer** can `show` and `history`. A member, admin or owner can also `do` and `undo`. - **Your team** signs in as Leaseclock's own admins and picks a customer with `--workspace`, as in `npx leaseclock show leases --workspace northgate-rentals`. - **Offer limits** hold in the CLI too. On Leaseclock Pro, `do Unit.create` stops at 200 Units with a link to upgrade. ## Commands | Command | What it does | | --- | --- | | `login` | Signs in through the browser and stores the token | | `show [id]` | Lists records, or shows one | | `do [id] [--input value]` | Runs a Verb, after a preview | | `undo ` | Reverses the last run of that Verb on that record | | `history ` | Lists every change to a record | | `help [Noun.verb]` | Lists every command, or explains one Verb | | Flag | What it does | | --- | --- | | `--json` | Prints records as the API's JSON, one per line | | `--yes` | Confirms without a prompt | | `--preview` | Prints the preview and runs nothing | | `--workspace ` | Picks a customer's workspace, for your team | | `--limit , --all` | Sets how many records a list returns | A Test key reaches the Test copy, with its own records and Stripe's test mode, and a Live key reaches Live, so the same command works in both. ## Questions ### Does anyone need to install it? No. `npx leaseclock` downloads and runs it. Anyone who uses it daily can run `npm i -g leaseclock` and type `leaseclock` instead. The package also holds the [SDK](https://saas.dev/docs/sdk.md). ### How is it different from `npx saas.dev`? [`npx saas.dev`](https://saas.dev/docs/saas-dev-cli.md) is for you: it creates your product and pushes its definition. `npx leaseclock` is your product's own CLI, for your team and your customers to work with records. Nothing in it names SaaS.Dev. ### What happens when I add a Verb? Push the file and the Verb is a command. Add `pause` to Lease, and `npx leaseclock do Lease.pause 4b` runs on the next call, with its own help line, preview and undo. ### Should my customers' agents use the CLI or MCP? MCP. An agent connected to `leaseclock.com/mcp` gets `search`, `fetch` and `do` as tools, with no shell to run. The CLI is for people at a terminal and for scripts. See [MCP](https://saas.dev/docs/mcp.md). --- url: https://saas.dev/docs/mcp md: https://saas.dev/docs/mcp.md --- # MCP Your customers' agents connect to `leaseclock.com/mcp` and get three tools: `search`, `fetch` and `do`. `do` runs any Verb, so a new Verb reaches every connected agent without a new tool. ## In short [An agent chat renewing lease 4B through the leaseclock do tool](https://leaseclock.com/mcp) - **Address:** `https://leaseclock.com/mcp`, over streamable HTTP. It goes live with your product, with nothing to set up. - **Sign-in:** the agent's app opens Leaseclock's own sign-in page. Scripts send a Leaseclock token instead. - **Tools:** `search` finds records, `fetch` reads one in full, and `do` runs a Verb. - **Permissions:** an agent works as the person who connected it, in that person's workspace, with that person's role. - **Brand:** the server is named `leaseclock`, and nothing an agent reads mentions SaaS.Dev. ## A renewal from Claude Priya Shah is the leasing coordinator at Northgate Rentals, one of Leaseclock's customers. She has connected Claude to Leaseclock and asks it: > Renew Dana Whitfield's lease in 4B through November 30, 2027. Claude finds the lease with `search`: ```json title="search" { "query": "Dana Whitfield 4B" } ``` ```json title="Result" { "results": [ { "id": "leases/4b", "title": "Lease 4B · dana.whitfield@example.com · ends 2026-11-30", "url": "https://leaseclock.com/app/leases/4b" } ] } ``` Then it renews the lease with `do`: ```json title="do" { "verb": "Lease.renew", "id": "4b", "input": { "endsOn": "2027-11-30" } } ``` ```json title="Result" { "id": "leases/4b", "$type": "Lease", "unit": "4b", "renter": "dana.whitfield@example.com", "endsOn": "2027-11-30", "status": "active", "renewedBy": "priya@northgaterentals.com", "url": "https://leaseclock.com/app/leases/4b" } ``` The renewal appears in Lease 4B's history in the app, under Priya's name, and anyone on her team with the right role can undo it there. Connect your own agent to [leaseclock.com](https://leaseclock.com) and try it. ## Connect an agent Any MCP client that speaks streamable HTTP connects with the one address. **Claude** In **Settings → Connectors**, choose **Add custom connector**, name it Leaseclock and paste the address: ```text https://leaseclock.com/mcp ``` **ChatGPT** In **Settings → Apps & Connectors**, create a connector named Leaseclock with the address: ```text https://leaseclock.com/mcp ``` `search` and `fetch` take the shapes ChatGPT expects, so Leaseclock also works in ChatGPT's deep research. **Claude Code** ```bash title="Terminal" claude mcp add --transport http leaseclock https://leaseclock.com/mcp ``` **Cursor** ```json title=".cursor/mcp.json" { "mcpServers": { "leaseclock": { "url": "https://leaseclock.com/mcp" } } } ``` **VS Code** ```json title=".vscode/mcp.json" { "servers": { "leaseclock": { "type": "http", "url": "https://leaseclock.com/mcp" } } } ``` ### Sign in The first tool call opens Leaseclock's sign-in page, in Leaseclock's teal. Priya signs in with her Northgate Rentals account and approves Claude. From then on Claude sees Northgate's records and nobody else's, and it can run only the Verbs her role allows. Each connected agent is listed in the app under **Settings → Connected agents**, where Priya or a Northgate admin can disconnect it. ### Connect with a token An agent that runs without a person, such as a nightly script or a CI job, sends a Leaseclock token in place of signing in. Create one in the app under **Settings → API keys** and keep it in the environment: ```bash title="Terminal" claude mcp add --transport http leaseclock https://leaseclock.com/mcp \ --header "Authorization: Bearer $LEASECLOCK_TOKEN" ``` A token carries the role it was created with. A Test token reaches the Test copy of the workspace, so an agent can try every Verb without touching a real lease. ## The tools ### search Finds records by text, by Noun or by field. Every result carries an `id` that `fetch` and `do` accept, and a `url` to the record in the app. ```json title="search: active leases ending this year" { "type": "Lease", "where": { "status": "active", "endsOn": { "before": "2027-01-01" } } } ``` ```json title="Result" { "results": [ { "id": "leases/4b", "title": "Lease 4B · dana.whitfield@example.com · ends 2026-11-30", "url": "https://leaseclock.com/app/leases/4b" }, { "id": "leases/2a", "title": "Lease 2A · marcus.bell@example.com · ends 2026-12-15", "url": "https://leaseclock.com/app/leases/2a" } ] } ``` | Input | Type | What it does | | --- | --- | --- | | `query` | string | Matches the text of any field | | `type` | string | Limits results to one Noun, such as `Lease` | | `where` | object | Filters on field values. An indexed field also takes `before` and `after`, as in `"endsOn": { "before": "2027-01-01" }` | | `limit` | number | Caps the results. The default is 20 and the most is 100. | ### fetch Reads one record in full: its fields, its linked records and the Verbs that can run on it now. ```json title="fetch" { "id": "leases/4b" } ``` ```json title="Result" { "id": "leases/4b", "title": "Lease 4B", "url": "https://leaseclock.com/app/leases/4b", "text": "Lease on Unit 4B, Alder Court. Renter dana.whitfield@example.com. Ends 2026-11-30. Status active.", "metadata": { "$type": "Lease", "unit": { "id": "units/4b", "label": "4B", "building": "Alder Court" }, "renter": "dana.whitfield@example.com", "endsOn": "2026-11-30", "status": "active", "verbs": ["renew", "end"] } } ``` `verbs` lists only what this record and this person allow. On Lease 5A, which has ended, the list is empty. ### do Runs a Verb on a record. `do` also creates and updates records, through the `create` and `update` every Noun has. | Input | Type | What it does | | --- | --- | --- | | `verb` | string | The Noun and Verb, such as `Lease.renew` or `Unit.create` | | `id` | string | The record to run it on. `create` takes none. | | `input` | object | The Verb's input, such as `{ "endsOn": "2027-11-30" }` | | `preview` | boolean | Returns the change without making it | With `preview`, nothing is saved: ```json title="do" { "verb": "Lease.renew", "id": "4b", "input": { "endsOn": "2027-11-30" }, "preview": true } ``` ```json title="Result" { "preview": true, "id": "leases/4b", "changes": { "endsOn": { "from": "2026-11-30", "to": "2027-11-30" } } } ``` `search` and `fetch` are marked read-only, so agent apps run them without asking. `do` is marked as a write, so most agent apps ask the person to approve each call before it runs. ## New Verbs reach every agent The `do` tool's description lists every Verb with its input, written from your definition. Add a Verb to Lease: ```diff title="Lease.mdx" verbs: renew: does: Move the end date forward input: { endsOn: { says: the new end date, type: date, required: true } } from: [active] end: { does: End the lease, from: [active], to: ended } + remind: { does: Email the renter that the lease is ending, from: [active] } ``` Push it, and `Lease.remind` is in the `do` tool on the next request. Connected agents get the MCP tools-changed notice and pick it up in the same conversation, so Priya can ask Claude to remind every renter whose lease ends in December. ## Your team's agent Your own team connects the same server from the admin. Beside your Nouns, the admin has the ones every product keeps for you: Workspace, Member, Subscription, Invoice and Changelog entry. Your team's agent searches them like any other record: ```json title="search: subscriptions that renew this week" { "type": "Subscription", "where": { "renewsOn": { "before": "2026-11-23" } } } ``` ```json title="Result" { "results": [ { "id": "subscriptions/northgate-rentals", "title": "Northgate Rentals · Leaseclock Pro · $29.00 a month · renews 2026-11-21", "url": "https://leaseclock.com/admin/billing/subscriptions/northgate-rentals" } ] } ``` ## What your definition sets Every line an agent reads comes from your files. | In your definition | What an agent gets | | --- | --- | | `name: Lease` | `Lease` in search results, and `Lease.renew` and `Lease.end` in `do` | | `does: Move the end date forward` | The description the agent reads before it calls `Lease.renew` | | `says: the new end date` | The hint on the `endsOn` input | | `from: [active]` | `renew` offered only on active leases, in `fetch`'s `verbs` | | `endsOn: { type: date, indexed: true }` | `where` filters on the end date in `search` | | `limits: { Unit: 200 }` in `pro.mdx` | `Unit.create` stops at 200 Units on Leaseclock Pro, with an upgrade link | | `price: { amount: 29, currency: USD, unit: month }` in `pro.mdx` | Subscription records, with `renewsOn` and the amount, for your team's agent | | Each push that customers can see | A Changelog entry record, readable by your team's agent before it is published | Write each `does:` line for an agent as much as for a person. It is the sentence an agent uses to decide which Verb fits the request. ## Errors A call that cannot run returns an MCP tool error with the reason, the fix and a link to the Verb's page in Leaseclock's docs. Nothing is saved. Renewing Lease 5A, which has ended: ```json title="Result" { "isError": true, "content": [ { "type": "text", "text": "Lease 5A is ended, and renew runs only on an active Lease. To keep Unit 5A let, create a new Lease with Lease.create. https://leaseclock.com/docs/lease#renew" } ] } ``` The agent reads the fix and tries again, or asks the person what to do. Every code is listed in [Errors](https://saas.dev/docs/errors.md). ## Questions **Can I add my own tool?** Add a Verb. Write its logic as the Verb's function, and `do` runs it with the same permissions, preview and undo as every other Verb. **Which agents work with it?** Any MCP client over streamable HTTP, including Claude, ChatGPT, Claude Code, Cursor and VS Code. **Can an agent see another customer's records?** No. An agent works inside the workspace of the person who connected it, and the server applies the same permissions as the app and the API. **Can an agent change something by mistake?** `do` runs only the Verbs the person's role allows, most agent apps ask before each `do` call, and every change is in the record's history with an undo. **Do MCP calls count against rate limits?** Yes. A connected agent shares the rate limits of the person or token it acts as, the same as calls to the [API](https://saas.dev/docs/api.md). --- url: https://saas.dev/docs/sdk md: https://saas.dev/docs/sdk.md --- # SDK `npm i leaseclock` installs a typed client generated from your definition. `$.Lease.renew('4b', { endsOn })` is checked by your editor before it runs. The package is republished whenever the definition changes. ## In short [An editor showing the leaseclock client's renew method and its hover type](https://leaseclock.com/docs/sdk) The client is published to npm as `leaseclock`, under your name. It calls `leaseclock.com/api`, its errors link to `leaseclock.com/docs`, and nothing in it names SaaS.Dev. Northgate Rentals' developers use it to script their own leases, and your team uses the same package, with the same permissions as the app. Each Noun is a property on the client. `$.Lease` has `list`, `get`, `create` and `update`, plus `renew` and `end` from the Lease file, and a Verb you add appears as a method in the next version. Field types, required fields and choice options become TypeScript types. A Verb's `does:` line is its method's hover text, and each input's `says:` line describes that argument. ## Leaseclock's client Northgate Rentals renews Dana Whitfield's lease on 4B from a script: ```ts title="renew.ts" import { Leaseclock } from 'leaseclock' const $ = new Leaseclock({ token: process.env.LEASECLOCK_TOKEN }) const lease = await $.Lease.renew('4b', { endsOn: '2027-11-30' }) lease.endsOn // '2027-11-30' lease.status // 'active' ``` Hovering `renew` in the editor shows the method as Lease.mdx declares it: ```ts title="Hover on renew" (method) Lease.renew( id: string, input: { endsOn: string } ): Promise // Move the end date forward. Runs from: active. // @param input.endsOn the new end date ``` A misspelled field is caught as it is typed, before anything reaches the API: ```ts title="renew.ts" await $.Lease.renew('4b', { endsAt: '2027-11-30' }) // ~~~~~~ // Object literal may only specify known properties, and 'endsAt' // does not exist in type '{ endsOn: string; }'. ``` ## How to call it ### Install and sign in ```bash title="Terminal" npm i leaseclock ``` Each caller creates a key in Leaseclock under **Settings → API keys**, sets it as `LEASECLOCK_TOKEN` and passes it to the client. A key acts with its owner's role, so Priya Shah's key reaches Northgate's records and no one else's. A Test key reaches the Test copy, where Stripe charges nothing. ```ts const $ = new Leaseclock({ token: process.env.LEASECLOCK_TOKEN }) ``` ### Read and write records `list` filters on any field and sorts on any field marked `indexed: true`. It returns up to 200 records a page, and `for await` walks every page. Priya's renewal reminder lists the active leases ending this year: ```ts title="ending-soon.ts" for await (const lease of $.Lease.list({ status: 'active', endsOn: { before: '2027-01-01' }, sort: 'endsOn', })) { console.log(lease.unit, lease.renter, lease.endsOn) } ``` ```text title="Output" 4b dana.whitfield@example.com 2026-11-30 2a marcus.bell@example.com 2026-12-15 ``` `get`, `create` and `update` take the same field names as the Lease file. A missing required field is a type error. ```ts const lease = await $.Lease.get('4b') await $.Lease.create({ unit: '5a', renter: 'ines.moreau@example.com', endsOn: '2027-10-31' }) await $.Lease.update('4b', { renter: 'dana.whitfield@northgate.example' }) ``` ### Run a Verb A Verb's method takes the record's id and the Verb's input, and returns the record after the change. Each call carries an idempotency key, so a retried `renew` after a dropped connection renews 4B once. `end` takes no input: ```ts await $.Lease.end('2a') ``` ### Preview and undo Pass `{ preview: true }` to see what a call would change. Nothing is saved, and no customer is emailed or charged. ```ts const preview = await $.Lease.renew('4b', { endsOn: '2027-11-30' }, { preview: true }) preview.changes // { endsOn: ['2026-11-30', '2027-11-30'] } ``` Every write is kept in the record's history, and `undo` reverses the latest one: ```ts await $.Lease.undo('4b') // endsOn is 2026-11-30 again ``` ### Handle errors Your editor catches wrong names and wrong types. What depends on the data, such as a lease's state or a plan's limit, comes back as a `LeaseclockError` with a status, a code and the fix: ```ts title="renew.ts" import { Leaseclock, LeaseclockError } from 'leaseclock' try { await $.Lease.renew('5a', { endsOn: '2027-09-30' }) } catch (err) { if (err instanceof LeaseclockError) { err.status // 422 err.code // 'wrong-state' err.message // 'renew runs from active, and Lease 5A is ended' err.docs // 'https://leaseclock.com/docs/errors#wrong_state' } } ``` Every code is listed with its cause and fix in [Errors](https://saas.dev/docs/errors.md). ## Types and schemas The package exports a type for every Noun and every Verb's input, so your own functions can take a `Lease`: ```ts import type { Lease } from 'leaseclock' function reminder(lease: Lease) { return `${lease.renter}: your lease on ${lease.unit.toUpperCase()} ends ${lease.endsOn}.` } ``` ```ts title="leaseclock/index.d.ts" export interface Lease { $id: string $type: 'Lease' unit: string // the Unit's id renter: string // an email address endsOn: string // a date, YYYY-MM-DD status?: 'active' | 'ended' } ``` `leaseclock/schemas` holds the same definition as JSON Schema, for checking a form or a file before it is sent. The `says:` line becomes each property's description: ```ts import { schemas } from 'leaseclock/schemas' schemas['Lease.renew'] ``` ```json title="schemas['Lease.renew']" { "type": "object", "properties": { "endsOn": { "type": "string", "format": "date", "description": "the new end date" } }, "required": ["endsOn"], "additionalProperties": false } ``` The API's OpenAPI file at `leaseclock.com/api/openapi.json` carries the same schemas, so a client in another language agrees with this one. ## New versions Each push that changes a Noun, a field or a Verb publishes a new version of `leaseclock`, and the changelog entry for that change names it. - Adding a field, a Verb or a Noun publishes a minor version. When Lease gained `rent`, the new version added `rent: number` to the `Lease` type, beside the changelog entry "Leases now track rent". - Renaming or removing one publishes a major version, so a project pinned to `^1` keeps building until its owner upgrades. ## Questions **Do I write or maintain the SDK?** No. It is generated from your definition on each push and published for you. Overrides you make to the app's screens or theme leave it untouched. **Where does it run?** Node, Bun, Deno, edge workers and the browser. In the browser, it uses the signed-in person's session instead of a key, so no key ships to a page. **Can my customers' agents use it?** Their agents connect to [MCP](https://saas.dev/docs/mcp.md), whose `do` tool runs the same calls. Agents that write code install `leaseclock` like any other package. **Does the SDK follow a renamed Noun?** Yes. Rename Lease to Tenancy and the next major version has `$.Tenancy`, the `Tenancy` type and `schemas['Tenancy.renew']`. The [CLI](https://saas.dev/docs/cli.md) and [API](https://saas.dev/docs/api.md) change in the same push. --- url: https://saas.dev/docs/site md: https://saas.dev/docs/site.md --- # Site Leaseclock's home page, pricing page, terms, privacy policy, `llms.txt` and OpenAPI file are served at `leaseclock.com`. They are written from the definition and set in your theme, and you can replace any page with your own. [Open leaseclock.com](https://leaseclock.com) ## In short [Leaseclock's home page, with the Leaseclock Pro card at $29 a month](https://leaseclock.com) | Page | Address | Written from | | --- | --- | --- | | Home | [leaseclock.com](https://leaseclock.com) | Who each Offer is for, and what each Verb does | | Pricing | [leaseclock.com/pricing](https://leaseclock.com/pricing) | Each Offer's name, price and limits | | Terms | [leaseclock.com/terms](https://leaseclock.com/terms) | Your business details in Stripe, and each Offer's price and billing period | | Privacy | [leaseclock.com/privacy](https://leaseclock.com/privacy) | The personal data your Nouns keep, and who processes it | | `llms.txt` | [leaseclock.com/llms.txt](https://leaseclock.com/llms.txt) | Every Noun, Verb, docs page and agent address | | OpenAPI | [leaseclock.com/openapi.json](https://leaseclock.com/openapi.json) | Every API operation, with its input and response | Sign in and Get started on every page open the app at `leaseclock.com/app`. Subscribe on the pricing page opens [Stripe Checkout](https://saas.dev/docs/stripe.md) for that Offer. ## Leaseclock's site A property manager who lands on [leaseclock.com](https://leaseclock.com) sees Leaseclock's teal logo, then Pricing, Docs and Blog in the header, and Sign in and Get started on the right. The headline reads "No lease renewal slips past its date." Under it sits Leaseclock's own Leases list, framed, and then the Leaseclock Pro strip: up to 200 units, and "Subscribe · $29 a month". The footer links Terms, Privacy, `llms.txt` and OpenAPI. Your home page shows your App's main list, captured from your Test copy on every push. Each line on the page comes from a line of the definition: | On leaseclock.com | From the definition | | --- | --- | | Leaseclock Pro, $29 a month | `name: Leaseclock Pro` and `price: { amount: 29, currency: USD, unit: month }` in `Offer/pro.mdx` | | Up to 200 units | `limits: { Unit: 200 }` | | For property managers and leasing coordinators | `personas: Property manager, Leasing coordinator` | | "Move the end date forward" and "End the lease", in the feature list | Each Verb's `does` in `Noun/Lease.mdx` | | The privacy policy's line "Leaseclock stores each renter's email address" | `renter: { type: email, required: true }` | | Teal buttons and links, set in Inter | `"brand": "#0F766E"` and `"font": "Inter"` in `theme.json` | The site follows the definition on the next request. Add a second Offer and the pricing page shows two cards. Add a `phone` field to Lease and the privacy policy lists phone numbers. ## Serve it on your domain Add the domain once. The CLI prints the DNS record to set, waits for it and issues the certificate. ```bash npx saas.dev domain add leaseclock.com ``` ```text Add this record at your DNS provider: leaseclock.com CNAME cname.saas.dev Checking DNS... found. Certificate issued for leaseclock.com. leaseclock.com serves Leaseclock. ``` The whole product shares the domain. The site is at `/`, `/pricing`, `/terms` and `/privacy`, the app at `/app`, the [API](https://saas.dev/docs/api.md) at `/api`, the [docs](https://saas.dev/docs/your-docs.md) at `/docs`, the [blog](https://saas.dev/docs/blog.md) at `/blog` and the [MCP server](https://saas.dev/docs/mcp.md) at `/mcp`. ## Agent files `llms.txt` tells an agent what Leaseclock is and where everything lives. It needs no key. ```bash curl https://leaseclock.com/llms.txt ``` ```md title="leaseclock.com/llms.txt" # Leaseclock > Leaseclock tracks lease renewals for property managers and leasing coordinators. ## Docs - [Lease](https://leaseclock.com/docs/lease.md): unit, renter, endsOn, status. Verbs: renew (move the end date forward), end (end the lease). - [Unit](https://leaseclock.com/docs/unit.md): label, building. - [API](https://leaseclock.com/docs/api.md): REST at https://leaseclock.com/api ## For agents - MCP server: https://leaseclock.com/mcp (tools: search, fetch, do) - OpenAPI: https://leaseclock.com/openapi.json - SDK: npm i leaseclock - CLI: npx leaseclock ## Pricing - [Leaseclock Pro](https://leaseclock.com/pricing): $29 a month, up to 200 units ``` The OpenAPI file describes every operation the API serves, so any client generator or agent framework can call Leaseclock. ```bash curl -s https://leaseclock.com/openapi.json | jq '.paths | keys' ``` ```json [ "/api/leases", "/api/leases/{id}", "/api/leases/{id}/end", "/api/leases/{id}/renew", "/api/units", "/api/units/{id}" ] ``` Both files are written from the current version of the definition, so a new Verb appears in them the moment it is served. ## Change a page A file in `pages/` replaces the page at the same address. Write it in MDX to change the words and keep the generated layout: ```mdx title="pages/index.mdx" --- headline: Every lease renewal, on time lede: Leaseclock watches the end date of every lease across your units and renews it in two clicks. --- ``` Write it in React to replace the whole page. The header, footer, theme and Stripe Checkout stay generated, and you maintain only the page you wrote. ```tsx title="pages/pricing.tsx" import { PricingCards } from 'saas.dev/site' export default function Pricing({ offers }) { return (

Pricing

Managing more than 200 units? Write to sales@leaseclock.com.

) } ``` Push, and leaseclock.com serves the new page on the next request. ```bash npx saas.dev push ``` [Defaults and overrides](https://saas.dev/docs/overrides.md) covers the theme, fields, views and screens the same way. ## Add a page A file in `pages/` with a new name adds a page. `pages/about.mdx` is served at `leaseclock.com/about`, set in Leaseclock's theme, listed in the sitemap and linked from the footer. ```mdx title="pages/about.mdx" --- title: About Leaseclock --- Leaseclock started inside a property management office that lost two renewals in one winter. ``` ## Questions ### Who writes the words on the home page? SaaS.Dev writes them from your definition: who each Offer is for, what each Verb does and what each plan costs. They change when the definition changes, except for the lines you set in `pages/`. ### Are the generated terms and privacy policy ready to publish? They name your business from your Stripe account, your prices and billing periods, the personal data your Nouns keep, and Stripe as your payment processor. Have your lawyer read them before you sell in Live, and put their text in `pages/terms.mdx` or `pages/privacy.mdx` to replace either page. ### Can I keep my existing marketing site? Yes. Add the product on a subdomain with [`npx saas.dev domain add app.leaseclock.com`](https://saas.dev/docs/saas-dev-cli.md), keep your own site at `leaseclock.com`, and point its Sign in and pricing buttons at `app.leaseclock.com`. ### How do search engines and agents find it? Every page has its own title, description and social image, and is listed in `leaseclock.com/sitemap.xml`. Agents read `llms.txt`, and every page has a Markdown copy at its address plus `.md`, such as `leaseclock.com/pricing.md`. ### Where do the prices come from? From your [Offers](https://saas.dev/docs/offers.md). Each Offer is one pricing card, and its price is the one Stripe charges, so the page and the checkout always agree. --- url: https://saas.dev/docs/your-docs md: https://saas.dev/docs/your-docs.md --- # Your docs `leaseclock.com/docs` has a page for every Noun and Verb, with examples that run against Test. Every page is also served as Markdown, so your customers' agents read the same docs your customers do. ## In short [Leaseclock's docs page for renewing a lease](https://leaseclock.com/docs/lease#renew) Your customers get a docs site at `/docs` on your domain, in your theme, and you write none of it. Each Noun has a page with its fields and the calls that list, read, create and update it. Each Verb has a section on its Noun's page, with its input and the same call in curl, TypeScript, the CLI and MCP. A field you add is documented the moment it exists. [Open leaseclock.com/docs](https://leaseclock.com/docs) ## Leaseclock's docs Leaseclock's docs open on Quickstart and API keys, then give Lease and Unit a page each. Priya Shah, the leasing coordinator at Northgate Rentals, opens [`leaseclock.com/docs/lease#renew`](https://leaseclock.com/docs/lease#renew) when she scripts the month's renewals. Every heading, row and example on that page comes from one file: ```yaml title="Noun/Lease.mdx" --- name: Lease fields: unit: { type: link, target: Unit, required: true } renter: { type: email, required: true } endsOn: { type: date, required: true, indexed: true } status: { type: choice, options: [{ key: active }, { key: ended }] } verbs: renew: does: Move the end date forward input: { endsOn: { says: the new end date, type: date, required: true } } from: [active] end: { does: End the lease, from: [active], to: ended } --- ``` Each line decides something a reader sees: | In the definition | On `leaseclock.com/docs` | | --- | --- | | `name: Lease` | The page `/docs/lease`, titled Lease, with its own group in the sidebar | | `unit: { type: link, target: Unit }` | A `unit` field that links to the Unit page | | `renter: { type: email }` | A `renter` field typed email, filled with Dana Whitfield's address in every Lease example | | `indexed: true` | `endsOn` listed among the list call's filters and sort keys | | `status: { type: choice, … }` | A `status` field with its two values, `active` and `ended` | | `required: true` | A *required* mark, and the error a call without the field returns | | `does: Move the end date forward` | A section titled "Renew a Lease" that opens with "Moves the end date forward" | | `says: the new end date` | The `endsOn` input, described as "The new end date" | | `from: [active]` | "Runs on an active Lease", and the error that renewing an ended Lease returns | | `end: { …, to: ended }` | A section titled "End a Lease" that says the Lease moves to `ended` | | `limits: { Unit: 200 }` in `Offer/pro.mdx` | "Leaseclock Pro allows 200 Units" on the Unit page, and the error the 201st Unit returns | ## Examples that run against Test Every example comes in four forms: curl, TypeScript, the CLI and MCP. A signed-in reader's examples point at their own Test workspace, and **Run** sends the call there and shows the response in place. Test is a full copy of Leaseclock where no real charge happens, so Priya can renew 4B from the docs without touching Northgate's live leases. These are the four forms of the renew example on `leaseclock.com/docs/lease#renew`: **API** ```bash curl -X POST https://leaseclock.com/api/leases/4b/renew \ -H "Authorization: Bearer $LEASECLOCK_TOKEN" \ -d '{ "endsOn": "2027-11-30" }' ``` **CLI** ```bash npx leaseclock do Lease.renew 4b --endsOn 2027-11-30 ``` **SDK** ```ts title="renew.ts" import { Leaseclock } from 'leaseclock' const $ = new Leaseclock({ token: process.env.LEASECLOCK_TOKEN }) const lease = await $.Lease.renew('4b', { endsOn: '2027-11-30' }) ``` **MCP** ```json title="do" { "verb": "Lease.renew", "id": "4b", "input": { "endsOn": "2027-11-30" } } ``` Run shows what the API returns: the Lease as it now stands, and the link that undoes the renewal. ```json title="200 OK" { "$id": "https://leaseclock.com/api/leases/4b", "$type": "Lease", "unit": "4b", "renter": "dana.whitfield@example.com", "endsOn": "2027-11-30", "status": "active", "renewedBy": "priya@northgaterentals.com", "undo": "https://leaseclock.com/api/leases/4b/undo" } ``` Each push of your definition runs every example against Test before the new version is served. A call printed in your docs is a call your API accepts. ## Markdown for agents Every docs page has a Markdown copy at the same path with `.md` added. A request for the page with `Accept: text/markdown` gets the same Markdown, and each page has a **Copy Markdown** button for pasting into Claude, ChatGPT or Cursor. ```bash curl https://leaseclock.com/docs/lease.md ``` ````md title="leaseclock.com/docs/lease.md" ## Renew a Lease Moves the end date forward. Runs on an active Lease, and keeps its status. | Input | Type | Description | | --- | --- | --- | | `endsOn` (required) | date | The new end date | ```bash curl -X POST https://leaseclock.com/api/leases/4b/renew \ -H "Authorization: Bearer $LEASECLOCK_TOKEN" \ -d '{ "endsOn": "2027-11-30" }' ``` Returns the Lease. Undo it with `POST /api/leases/4b/undo`. ```` [`leaseclock.com/llms.txt`](https://leaseclock.com/llms.txt) lists every docs page with a one-line summary, so an agent finds the Lease page without crawling the site. The [Site](https://saas.dev/docs/site.md) page covers `llms.txt` and the OpenAPI file. ## Your own pages Write a guide, such as one on renewing a building's leases in one pass, as an MDX file in `Docs/` and push it with the definition. `Docs/bulk-renewals.mdx` is served at `leaseclock.com/docs/bulk-renewals`, in the sidebar beside the generated pages, in your theme, with its own Markdown copy and its own line in `llms.txt`. Text under a Noun's frontmatter opens that Noun's page: ```mdx title="Noun/Lease.mdx" --- name: Lease # fields and verbs as before --- A Lease ties a renter to a Unit until a date. Renew it before `endsOn` and the renter keeps the unit. ``` To replace a generated page with your own React component, see [Defaults and overrides](https://saas.dev/docs/overrides.md). ## Questions **When does a change reach the docs?** On the next request after you push it. When Lease gained a `rent` field, `leaseclock.com/docs/lease` gained a `rent` field typed money, the create example gained a rent value, and the changelog entry "Leases now track rent" linked to the page. The [Blog](https://saas.dev/docs/blog.md) page covers the changelog. **Do the docs carry my brand?** Yes. They are served at `leaseclock.com/docs` in Leaseclock's teal theme, under Leaseclock's name. Nothing on them names SaaS.Dev. **Who can read them?** Anyone, without signing in. Run needs a signed-in reader, and sends calls only to that reader's own Test workspace. Each reader sees the Verbs their role may run, so a Verb kept to your team appears only in your team's docs. **Which languages do the examples use?** curl, TypeScript with the `leaseclock` package, the `npx leaseclock` CLI, and MCP tool calls. The [SDK](https://saas.dev/docs/sdk.md), [CLI](https://saas.dev/docs/cli.md) and [MCP](https://saas.dev/docs/mcp.md) pages cover each one. **Can a customer's agent read the docs and act on them?** Yes. The agent reads `leaseclock.com/docs/lease.md` to learn renew's input, then calls `do` on `leaseclock.com/mcp` with that same input, under the customer's own permissions. --- url: https://saas.dev/docs/blog md: https://saas.dev/docs/blog.md --- # Blog `leaseclock.com/blog` leads with Leaseclock's newest post, with the newest changelog entry under it. The changelog writes itself: when Lease gained a `rent` field, the entry "Leases now track rent" appeared, listing the new column, property and type. ## In short [Leaseclock's blog: the article "Why renewals slip, and the 60-day notice", then the newest changelog entry](https://leaseclock.com/blog) - The blog is at `leaseclock.com/blog`, in your theme, with an RSS feed and a Markdown copy of every post. - The changelog at `leaseclock.com/blog/changelog` gains an entry each time a push changes what your customers see. - A post is a Markdown file in `Post/`, pushed like any other file. ## Leaseclock's changelog On October 21, Leaseclock added one line to `Lease.mdx` and pushed it. ```diff title="Noun/Lease.mdx" renter: { type: email, required: true } endsOn: { type: date, required: true, indexed: true } + rent: { type: money } status: { type: choice, options: [{ key: active }, { key: ended }] } ``` The Leases list gained a Rent column, the API and SDK gained a `rent` property, and [leaseclock.com/blog/changelog](https://leaseclock.com/blog/changelog) gained this entry, in Leaseclock's teal: ```md title="leaseclock.com/blog/changelog/leases-now-track-rent" # Leases now track rent October 21, 2026 Every lease has a monthly rent. Set it when you create a lease, and edit it on the lease's page. - **New column:** Rent, in the Leases list - **New property:** `rent` on `Lease` in the API and SDK - **Type:** money, in dollars ``` Northgate Rentals reads it on the site, in a feed reader, or through an agent that fetches the Markdown. ## What writes an entry Every change your customers can see writes one entry, worded the way a customer reads it. | You change | The entry | | --- | --- | | Add `rent` to Lease | "Leases now track rent", with the new column, property and type | | Add the `end` Verb to Lease | "End a lease from its page", with the new button, endpoint, command and agent tool | | Add a `renewing` option to `status` | "Leases can be marked renewing", with the new tab and the new value | | Add a Noun, such as Inspection | "Track inspections", with the new list, record page and endpoints | | Raise `limits: { Unit: 200 }` to 300 | "Leaseclock Pro now covers 300 units" | | Rename `renter` to `renterEmail` | Marked **Breaking**, with the old and new property and the SDK version that carries the change | | Change a Verb's function body, the theme or a component of your own | No entry. Write a post when customers should hear about it | ## Edit or hide an entry A new entry appears first on the Test copy of your site, so your team reads it before any customer does. In the admin's Changelog screen, rewrite its title or summary, or hide it. The list of what changed stays as generated, so it always matches the API and SDK. ## Write a post A post is a Markdown file in `Post/`, next to your Nouns and Offers. Your agent writes it the same way it writes `Lease.mdx`. ```mdx title="Post/northgate-rentals.mdx" --- title: How Northgate Rentals renews every lease on time date: 2026-10-23 summary: Priya Shah sees every lease that ends in the next 60 days, and renews each one in two clicks. --- Northgate Rentals manages 24 units across Alder Court and Birch House. Priya Shah, its leasing coordinator, starts each Monday on the Leases list, sorted by end date ... ``` ```bash title="Terminal" npx saas.dev push ``` [`npx saas.dev push`](https://saas.dev/docs/saas-dev-cli.md) serves the post at `leaseclock.com/blog/northgate-rentals` on Test, in your theme, with its title and summary as the link preview. It goes Live with the rest of your definition (see [How it works](https://saas.dev/docs/how-it-works.md)). Add `draft: true` to keep a post on Test. ## Read it from code Everything the blog serves is public, and needs no key. | Address | What it serves | | --- | --- | | `leaseclock.com/blog` | Every post, newest first | | `leaseclock.com/blog/northgate-rentals` | One post | | `leaseclock.com/blog/changelog` | Every changelog entry, newest first | | `leaseclock.com/blog/feed.xml` | An RSS feed of posts | | `leaseclock.com/blog/changelog/feed.xml` | An RSS feed of changelog entries | Ask for any of them as Markdown, the way your customers' agents do: ```bash curl -H 'Accept: text/markdown' https://leaseclock.com/blog/changelog ``` Every post and entry is also listed in `leaseclock.com/llms.txt` and the sitemap. ## Questions **Does SaaS.Dev appear anywhere on my blog?** No. The blog is on your domain, in your theme, under your product's name. **Can my team write a post without the CLI?** Yes. The admin's Posts screen writes the same file, and saving it creates a new version like a push does. **Can I change how a post looks?** Set fonts and colours in your theme, or replace the post layout with your own React component. See [Defaults and overrides](https://saas.dev/docs/overrides.md). You maintain only the component you replace. **Do changes SaaS.Dev makes write entries in my changelog?** No. Your changelog follows your definition. When SaaS.Dev improves checkout or the admin, the improvement reaches Leaseclock the day it ships, and SaaS.Dev's own [changelog](/changelog) records it. --- url: https://saas.dev/docs/stripe md: https://saas.dev/docs/stripe.md --- # Stripe Connect your Stripe account once. SaaS.Dev creates the product and monthly price for Leaseclock Pro, serves the checkout, keeps subscriptions in sync and gives each customer a billing portal. Buy it in Test with card `4242 4242 4242 4242`, then switch to Live. ## In short [Stripe Checkout in Leaseclock teal: Subscribe to Leaseclock Pro, $29.00 per month](https://leaseclock.com/pricing) [Northgate Rentals' billing portal: Leaseclock Pro, the card on file and paid invoices](https://leaseclock.com/app/billing) You write the plan in one file. SaaS.Dev turns it into a Stripe product and price, a pricing card on your site, a checkout, a subscription record in each customer's workspace, a billing portal and a limit the API enforces. Customers pay you through your own Stripe account, and Stripe pays out to your bank. SaaS.Dev takes no share of what your customers pay. ```mdx title="Offer/pro.mdx" --- name: Leaseclock Pro price: { amount: 29, currency: USD, unit: month } limits: { Unit: 200 } personas: Property manager, Leasing coordinator status: Live --- ``` | In the Offer | What it becomes | | --- | --- | | `name: Leaseclock Pro` | The Stripe product, the title on the pricing card and the line item at checkout | | `price: { amount: 29, currency: USD, unit: month }` | A Stripe price of $29.00 a month, "$29 a month" on the pricing page, and a charge on the same day each month | | `limits: { Unit: 200 }` | Room for 200 Units in each workspace. The 201st returns an error that names the plan and its limit | | `personas: Property manager, Leasing coordinator` | "For property managers and leasing coordinators" on the pricing card | ## Leaseclock Pro on leaseclock.com Priya Shah, the leasing coordinator at Northgate Rentals, opens [leaseclock.com/pricing](https://leaseclock.com/pricing) and presses Subscribe on Leaseclock Pro. Stripe Checkout opens in Leaseclock's teal, headed "Subscribe to Leaseclock Pro" at $29.00 per month, billed monthly, for up to 200 units. She pays with the company card and lands in Northgate Rentals' new workspace, on its Leases list. The receipt comes from Leaseclock. Northgate Rentals' 24 units fit inside the plan. A month later Priya opens Billing from the account menu and lands in Stripe's billing portal, also in teal, where she replaces the card on file and downloads the first invoice. Nothing on the way names SaaS.Dev. ## Get paid ### Connect your Stripe account Run [`npx saas.dev stripe connect`](https://saas.dev/docs/saas-dev-cli.md), or press Connect Stripe on the Billing screen in your admin, and sign in to Stripe. The account stays yours, and you can revoke the connection from Stripe's dashboard at any time. One connection covers Test and Live. Then push the Offer. SaaS.Dev creates the Stripe product and its monthly price, so you create neither by hand. ```console title="Terminal" $ npx saas.dev stripe connect Opening Stripe to connect leaseclock.com Connected to the Stripe account Leaseclock, in Test and Live $ npx saas.dev push Offer/pro.mdx Checked Offer/pro.mdx Created the Stripe product Leaseclock Pro Created the Stripe price $29.00 USD a month Pushed to Test ``` ### Check the price Open [leaseclock.com/pricing](https://leaseclock.com/pricing). The card reads "Leaseclock Pro, $29 a month", rendered from `amount`, `currency` and `unit`, so the page and the charge always agree. ### Buy it in Test On the Billing screen, press Buy in Test, and subscribe with Stripe's test card `4242 4242 4242 4242`, any future expiry date and any CVC. SaaS.Dev checks the signature on Stripe's event and records the subscription once, however many times Stripe delivers it. With a Test key in `LEASECLOCK_TOKEN`, the CLI reads the Test copy: ```console title="Terminal" $ npx leaseclock show subscriptions WORKSPACE OFFER STATUS PRICE RENEWS Northgate Rentals Leaseclock Pro active $29.00 / month 2026-11-21 ``` To see how Leaseclock handles a card that fails, buy again with one of Stripe's other test cards. | Card | What happens | | --- | --- | | `4000 0025 0000 3155` | The bank asks for authentication, and Checkout shows the challenge | | `4000 0000 0000 9995` | The card is declined for insufficient funds, and Checkout asks for another card | | `4000 0000 0000 0341` | The first payment succeeds and the next renewal fails, so the subscription reads `past_due` | ### Go live ```console title="Terminal" $ npx saas.dev push --live Checked Noun/Unit.mdx, Noun/Lease.mdx and Offer/pro.mdx Created the live Stripe product Leaseclock Pro Created the live Stripe price $29.00 USD a month Pushed to Live: https://leaseclock.com ``` A property manager such as Northgate Rentals picks Leaseclock Pro, pays through Stripe Checkout and becomes your customer. `unit: month` renews the subscription every month. Customers change their card or cancel in Stripe's billing portal. ### Money in your account The money lands in your own Stripe account, and Stripe pays it out to your bank on your payout schedule. To change the price, edit `amount` and push the Offer again. The next checkout sells the new price, and every subscription keeps the price it bought. ```diff title="Offer/pro.mdx" - price: { amount: 29, currency: USD, unit: month } + price: { amount: 39, currency: USD, unit: month } ``` ```console title="Terminal" $ npx saas.dev push Offer/pro.mdx --live Created the live Stripe price $39.00 USD a month New checkouts sell $39.00. Northgate Rentals keeps $29.00. ``` ## Read billing from code A subscription is a record in Leaseclock like a Lease or a Unit, so your team and your scripts read it from every generated surface. Each customer's agent reads its own workspace's subscription, and nobody else's. **API** ```bash title="Terminal" curl https://leaseclock.com/api/subscriptions/northgate-rentals \ -H "Authorization: Bearer $LEASECLOCK_TOKEN" ``` ```json title="200 OK" { "$id": "https://leaseclock.com/api/subscriptions/northgate-rentals", "$type": "Subscription", "workspace": "northgate-rentals", "offer": "pro", "status": "active", "price": { "amount": 29, "currency": "USD", "unit": "month" }, "renewsOn": "2026-11-21", "portal": "https://leaseclock.com/app/billing" } ``` **CLI** ```console title="Terminal" $ npx leaseclock show subscriptions/northgate-rentals Northgate Rentals · Leaseclock Pro · active $29.00 / month, renews 2026-11-21 Billing portal: https://leaseclock.com/app/billing ``` **SDK** ```ts title="billing.ts" import { Leaseclock } from 'leaseclock' const $ = new Leaseclock({ token: process.env.LEASECLOCK_TOKEN }) const sub = await $.Subscription.get('northgate-rentals') sub.status // 'active' sub.renewsOn // '2026-11-21' ``` Your own account at SaaS.Dev answers at `api.saas.dev`. Check the Stripe connection and what each Offer sells from a script, with the token `npx saas.dev login` stores, issued by id.org.ai: ```bash title="Terminal" curl https://api.saas.dev/products/leaseclock.com/stripe \ -H "Authorization: Bearer $SAAS_DEV_TOKEN" ``` ```json title="200 OK" { "connected": true, "account": "Leaseclock", "modes": ["test", "live"], "offers": [ { "offer": "pro", "product": "Leaseclock Pro", "price": { "amount": 29, "currency": "USD", "unit": "month" } } ] } ``` ## Questions ### Does SaaS.Dev take a share of each payment? No. Stripe charges its standard fee on each payment, and the rest is yours. SaaS.Dev bills you separately: your first product is free, and each one after it is $49 a month. ### Do I set up webhooks or API keys? No. Connecting your account registers Stripe's events with SaaS.Dev, which checks each signature and applies each event once. No Stripe key appears in your files or your code. ### Can I use the Stripe account I already have? Yes. Connect it, and SaaS.Dev adds one product for each Offer. Your existing products, customers and payouts stay as they are. ### Can I sell more than one plan, or bill yearly? Yes. Add a second Offer file, such as `Offer/team.mdx`, and the pricing page shows both cards, each with its own checkout. Set `unit: year` and Stripe gets a yearly price. ### What happens when a renewal fails? Stripe retries the card on your Stripe retry schedule and emails the customer a link to update it. The subscription reads `past_due` in your admin until a retry succeeds, the customer updates the card in the billing portal, or the subscription ends. ### What happens to billing if I leave SaaS.Dev? Your Stripe account, its customers and their subscriptions stay in Stripe, where they always were. One call exports your definition files and every record. --- url: https://saas.dev/docs/overrides md: https://saas.dev/docs/overrides.md --- # Defaults and overrides Each decision a SaaS needs, such as how sign-in works, who can see what, and what an empty list says, already has a default. Each default sits beside the way to override it, from a three-line theme to your own React component, with what you maintain afterwards. ## Defaults you can change | Question | Default | To change it | | --- | --- | --- | | What does it look like? | A neutral theme in light and dark, on every screen, page and email, and on Stripe Checkout | Set a font and two colours in [`theme.json`](#theme), or add [CSS](#css) | | What does an empty list say? | "No leases yet", with a button to add the first one | Set the words in [`copy.json`](#words) | | How does sign-in work? | A sign-in page on your domain, in your theme, where each customer invites their own team | Replace it with [your own page](#a-page). Sessions and roles stay generated | | Who may do what? | Four roles: owner, admin, member and viewer. Viewers read, members run Verbs, admins manage the team and the owner manages billing | Limit a Verb to some roles with `roles: [owner, admin]` on the Verb | | How is a field entered? | The input for its type: a date picker for a date, an input that rejects a non-address for an email | [Your own field component](#a-field) | | What does a list screen show? | At most 7 columns, and one button per Verb | [Your own view](#a-view) | | What does a record page show? | Every field, a button per Verb and the record's full history | [Your own view](#a-view) | | What does a Verb do? | Writes its input onto the record and moves it to the Verb's `to` state | [Your own function](#a-verbs-logic) | | What does the home page say? | Your product's name, what it keeps and its plans, written from the definition | [Your own page](#a-page) | | Where does the price live? | In the Offer, as one amount and currency that the pricing page, Checkout and the API all read | Edit `price` in [the Offer](https://saas.dev/docs/offers.md) and push | ## Decisions that stay fixed Some decisions hold on every surface, so a retried request, a script and a customer's agent behave the same in the app, the API, the CLI and MCP. [Constraints](https://saas.dev/docs/constraints.md) gives the reason for each one. | Question | Answer | | --- | --- | | Who sees a new record? | Only the people its workspace grants. Every record starts private. | | What does delete do? | It saves a new version, so nothing is lost and `revert` brings the record back. | | Can a GET change anything? | No. A GET on a Verb's URL previews the change and runs nothing. | | Does a retried Verb run twice? | No. A Verb's POST carries an Idempotency-Key, and a retry with the same key returns the first response. | | How much does a script commit? | All its writes, or none. | | How many records come back in a list? | At most 200 a page. | | What are the methods called? | `get`, `list`, `find`, `create`, `update` and `revert`, plus each Verb you declare. | ## Where overrides live Each override is a file in the same folder as Leaseclock's Noun and Offer files. `npx saas.dev push` sends it with the rest, and every surface has it on the next request. To return to a default, delete the file and push again. ## Theme Leaseclock's teal comes from three lines. They set the font, the brand colour and the text colour for every screen, page and email, and for Stripe Checkout and the billing portal. ```json title="theme.json" { "font": "Inter", "brand": "#0F766E", "ink": "#0F1F1D" } ``` You maintain three values. The dark theme, hover states and focus rings are worked out from them, at a readable contrast. ## CSS Every generated element carries a stable class name, so a rule keeps matching through each release. Leaseclock tightens its list rows so a property manager sees more leases on one screen. ```css title="theme.css" .list td { padding-block: 6px; } ``` You maintain one rule. ## Words Every label, button, empty list and email has default words, written from the Noun and Verb names. Set any of them by key. Each key names the Noun, then the screen or Verb, then the part. ```json title="copy.json" { "Lease.list.empty": "No leases yet. Add a lease to start tracking its end date.", "Lease.renew.button": "Renew lease" } ``` You maintain those strings. Every other word stays generated, and a new Noun or Verb arrives with words of its own. ## A field A field component replaces the input for one field wherever it appears: the create form, the edit form and the Renew popover. Priya Shah renews most leases for a year, so Leaseclock adds presets to the end date. ```tsx title="fields/endsOn.tsx" import type { FieldProps } from 'saas.dev/react' export default function EndsOn({ value, onChange }: FieldProps<'Lease', 'endsOn'>) { const add = (months: number) => { const date = new Date(value) date.setUTCMonth(date.getUTCMonth() + months) onChange(date.toISOString().slice(0, 10)) } return (
onChange(e.target.value)} />
) } ``` The Renew popover opens on the current end date, so on Dana Whitfield's lease in Unit 4B, +12 months sets 2027-11-30. `FieldProps` comes from `saas.dev/react`, typed from your definition: [`npx saas.dev pull`](https://saas.dev/docs/saas-dev-cli.md) writes `.saas/types.d.ts`, so renaming `endsOn` in `Lease.mdx` shows up here as a type error. You maintain one component. The label, the required mark and the check that the value is a date stay generated. ## A view A view replaces one screen of the app. `views/Lease.tsx` replaces a Lease's record page, and `views/Lease.list.tsx` replaces the Leases list. Leaseclock's record page leads with the unit and the end date. ```tsx title="views/Lease.tsx" import type { ViewProps } from 'saas.dev/react' export default function LeasePage({ lease, history, Verbs }: ViewProps<'Lease'>) { return (

Unit {lease.unit.label}, {lease.unit.building}

{lease.renter}. Ends {lease.endsOn}.

    {history.map((version) => (
  1. {version.at}: {version.summary}
  2. ))}
) } ``` `` renders the generated Renew and End buttons, with their popovers, permissions and disabled states. You maintain one screen. Sign-in, permissions, the Renew popover and the API stay generated, and the view keeps your theme. ## A page A page replaces one page of your site, such as the home page, the pricing page or sign-in. Leaseclock's home page sets its own headline and reads the price from the Offer, so the page and the charge always agree. ```tsx title="pages/index.tsx" import type { PageProps } from 'saas.dev/react' export default function Home({ offers }: PageProps) { const pro = offers.pro return (

Know which leases end next

Leaseclock shows your team every lease end date across your buildings, and renews a lease in one step.

{pro.name}, ${pro.price.amount} a month
) } ``` You maintain one page. Its URL, the header and footer, and the sitemap stay generated. See it live at [leaseclock.com](https://leaseclock.com). ## A Verb's logic A Verb's default writes its input onto the record. To add a rule, export a function with the Verb's name after the front matter of the Noun's file. Leaseclock rejects a renewal that moves the end date backwards. ```mdx title="Noun/Lease.mdx" export const renew = async (lease, { endsOn }) => { if (endsOn <= lease.endsOn) throw new Error('endsOn must be later') return { endsOn } } ``` When Priya enters an earlier date, the Renew popover shows the message, and an API caller gets a 422 with the same words. A preview checks the same rule, so a GET on the Verb's URL returns the message and changes nothing. [Errors](https://saas.dev/docs/errors.md) lists every code. You maintain one function. Permissions, preview, undo, history and the Idempotency-Key still wrap it. ## Choosing an override Every default you keep improves with each SaaS.Dev release. When the generated record page gains a feature, Unit's page has it the same day, and Leaseclock's own Lease page does not, because Leaseclock maintains it. Start with the theme and words, add CSS next, and write a component only for what those cannot say. The fewer defaults you replace, the more improvements you get for free. --- url: https://saas.dev/docs/constraints md: https://saas.dev/docs/constraints.md --- # Constraints A Noun is a name, its fields and its Verbs. Field keys are camelCase, and a Verb moves a record between named states. Each rule exists because some surface depends on it, and each one names that surface. Inside these rules, your agent writes a Noun file right the first time, and each improvement to a generated surface reaches your product the day it ships. ## The shape of a Noun This is Leaseclock's Lease, in full: ```yaml title="leaseclock.com/Noun/Lease.mdx" --- name: Lease fields: unit: { type: link, target: Unit, required: true } renter: { type: email, required: true } endsOn: { type: date, required: true, indexed: true } status: { type: choice, options: [{ key: active }, { key: ended }] } verbs: renew: does: Move the end date forward input: { endsOn: { says: the new end date, type: date, required: true } } from: [active] end: { does: End the lease, from: [active], to: ended } --- ``` The file holds the three keys that make up the Noun: `name`, `fields` and `verbs`. A Noun that people only keep, such as Unit, leaves out `verbs`. The folder says what kind of definition a file is, and its path is its address, so `Noun/Lease.mdx` is the Noun at `leaseclock.com/Noun/Lease` and never repeats either. ## What one name becomes Each name in the file appears on every surface, spelled the same way or derived from it by one fixed rule: | Surface | `Lease` | `endsOn` | `renew` | | --- | --- | --- | --- | | App | "Leases" in the sidebar | An "Ends on" column and a date picker | A Renew button on each active lease | | API | `leaseclock.com/api/leases` | `"endsOn": "2027-11-30"` | `POST /api/leases/4b/renew` | | CLI | `npx leaseclock show leases` | `--endsOn 2027-11-30` | `npx leaseclock do Lease.renew 4b` | | SDK | `$.Lease` | `lease.endsOn` | `$.Lease.renew('4b', { endsOn })` | | MCP | `search` returns `leases/4b` | `"input": { "endsOn": … }` | `do` with `"verb": "Lease.renew"` | | Docs | `leaseclock.com/docs/lease` | The Ends on row of the Lease page | `leaseclock.com/docs/lease#renew` | Rename Lease to Tenancy and every cell follows: `/api/tenancies`, `$.Tenancy`, "Tenancies" in the sidebar. ## The rules ### Keys a Noun takes A Noun file takes `name`, `fields` and `verbs`, and no other key. Every surface reads those three keys, so a key outside them would reach one surface and miss the rest. ```text 422 Unit.mdx line 3: a Noun takes name, fields and verbs. Rename "properties" to "fields". ``` ### Noun names A Noun's name starts with a capital letter and holds letters and digits, up to 64 characters: `Lease`, `Unit`, `RentalAgreement`. The name is the URL segment (`leases`), the screen title ("Leases"), the SDK namespace (`$.Lease`), the CLI target (`Lease.renew`) and the docs page (`/docs/lease`), and each is derived from that one spelling. ```text 422 Lease.mdx line 2: name "lease" must start upper-case. Rename it to "Lease". ``` ### Field keys and Verb names Field keys and Verb names are camelCase: `endsOn`, `renew`, `markPaid`. Each one is a JSON key in the API, a property in the SDK, a flag in the CLI and an argument your customers' agents send. The App writes its label from the same key, so `endsOn` heads a column "Ends on" and `markPaid` labels a button "Mark paid". ```text 422 Lease.mdx line 6: field "EndsOn" must start lower-case. Rename it to "endsOn". ``` ### Field types Every field has a `type` from the set on [Nouns](https://saas.dev/docs/nouns.md): `text`, `email`, `date`, `money`, `link`, `choice` and the rest. The type decides the list column, the form input, the check on every write, the JSON schema and the SDK type, so `endsOn` gets a date picker in the App and an ISO date such as `2027-11-30` in the API and the SDK. ```text 422 Unit.mdx line 6: field "bedrooms" has no type "integer". Use "number". ``` ### Links A `link` field names its `target`, and the target is a Noun in the same product. Lease's `unit` points at Unit, so the form offers a Unit picker, the list shows the unit as a link, and the API rejects a lease for a unit that does not exist. ```text 422 Lease.mdx line 4: field "unit" links to "Units", and no Noun has that name. Use "Unit". ``` ### Verbs and states A Verb says what it does in `does`, and that sentence is the docs line, the CLI help, the SDK hover text and the description your customers' agents read. A Verb that changes state names it: `from` lists the states a record must be in, and `to` names the state it lands in. Both are options of the Noun's `status` field. Only Verbs change `status`. The Lease form has no status box, and every change of state has a button, an endpoint, a history entry and an undo. The App disables Renew on an ended lease and shows why, and every other surface answers the same way: ```bash title="Terminal" npx leaseclock do Lease.renew 5a --endsOn 2027-09-30 # 409 Lease 5A is ended. Renew runs only on an active lease. ``` A state that `status` does not list is an error on push: ```text 422 Lease.mdx line 13: verb "end" moves to "closed", which is not a status. Use "ended", or add "closed" to status. ``` ### Prices What your customers pay you lives in an Offer, as an `amount`, a `currency` and a `unit`: ```yaml title="leaseclock.com/Offer/pro.mdx" price: { amount: 29, currency: USD, unit: month } limits: { Unit: 200 } ``` The pricing page, the Stripe price, Checkout and the upgrade prompt in the App all read that one line, so they always agree. `limits` sets how many Units each customer keeps, and the API holds every customer to it. A `money` field such as a lease's rent is a record's data, and it charges nobody. ```text 422 pro.mdx line 3: price has no key "interval". Use "unit: month". ``` ### Logic Code you write runs as a Verb's function, in `fn`. Renew's function sets the new end date, and the Verb around it gives that code its button, its endpoint, its CLI command, its tool for agents, a preview before it runs and an undo after. A screen of your own reads and writes through the same Verbs, so permissions and undo hold there too. [Defaults and overrides](https://saas.dev/docs/overrides.md) shows how to replace a screen. ## When a file breaks a rule [`npx saas.dev push`](https://saas.dev/docs/saas-dev-cli.md) checks every file before it saves anything. When lines break a rule, the push saves nothing and returns every one at once, each with the file, the line and the fix: ```console title="Terminal" $ npx saas.dev push 422 Lease.mdx line 6: field "EndsOn" must start lower-case. Rename it to "endsOn". 422 pro.mdx line 4: limits names "Units", and no Noun has that name. Use "Unit". $ npx saas.dev push --fix Pushed version 2 · https://my-product.test.saas.dev ``` `--fix` applies each error's edit, then previews. With `--json`, every error carries the same edit as `{ file, line, from, to }`, so your agent can apply it itself. Every code has an entry in [Errors](https://saas.dev/docs/errors.md) with its cause and fix. ## What fits SaaS.Dev fits products made of records and the things people do to them. Leaseclock is one: it keeps Units and Leases, and property managers renew and end leases. Trackers, CRMs, booking and scheduling tools, client portals and back offices take the same shape. Inside a product that fits, the parts that are yours have a place. A custom screen replaces one view and keeps your theme and permissions, and custom logic is a Verb's function. The fewer defaults you replace, the more improvements reach your product for free. Games, document and design editors, and video or audio tools need a different platform. Their core is a canvas, a game loop or a media stream, and no list, form or endpoint describes it. --- url: https://saas.dev/docs/how-it-works md: https://saas.dev/docs/how-it-works.md --- # How it works You push files, and SaaS.Dev checks them and saves them as a new version. Every page, endpoint and screen is generated from the current version on each request, so a change to your definition or to a default reaches every surface at once. ## Push a change Leaseclock's property managers want to see each lease's rent. Add one field to Lease: ```diff title="Noun/Lease.mdx" endsOn: { type: date, required: true, indexed: true } + rent: { type: money } status: { type: choice, options: [{ key: active }, { key: ended }] } ``` Preview the push. It checks every file and lists what changes on each surface, and it saves nothing: ```bash title="Terminal" $ npx saas.dev push --preview Checked Noun/Unit.mdx, Noun/Lease.mdx and Offer/pro.mdx Version 19 would replace version 18 in Test Lease gains rent (money) App A Rent column on Leases, and a money input in New lease and Edit lease API "rent" on every lease from /api/leases SDK rent: number on Lease, in leaseclock 1.4.0 CLI --rent on do Lease.create and do Lease.update Docs rent on leaseclock.com/docs/lease Changelog "Leases now track rent" Nothing breaks. Preview only, nothing is saved. ``` Push it to Test, check it there, then push it to Live: ```bash title="Terminal" $ npx saas.dev push Checked Noun/Unit.mdx, Noun/Lease.mdx and Offer/pro.mdx Saved version 19 Pushed to Test: https://test.leaseclock.com $ npx saas.dev push --live Checked Noun/Unit.mdx, Noun/Lease.mdx and Offer/pro.mdx Live serves version 19, the version Test serves Pushed to Live: https://leaseclock.com ``` On the next request, Priya Shah's Leases list at Northgate Rentals has a Rent column, `GET /api/leases/4b` returns `rent` for Dana Whitfield's lease, `npm i leaseclock` installs 1.4.0, and the changelog at [leaseclock.com/blog/changelog](https://leaseclock.com/blog/changelog) reads "Leases now track rent". Nothing generated lands in your repo, which holds only the files you wrote. ## Preview Add `--preview` to any push, to Test or to Live. The preview runs the same checks as the push, names every surface the change reaches, and marks each change that could stop a customer's script. A file that breaks a rule stops the preview with the line and the fix, as [Constraints](https://saas.dev/docs/constraints.md) shows. Adding a Noun, a field, a Verb or an Offer adds to every surface, and every existing call keeps working. Renaming or removing one is breaking, and the preview says what each surface loses: ```diff title="Noun/Unit.mdx" label: { type: text, required: true } - building: { type: text } ``` ```bash title="Terminal" $ npx saas.dev push --preview Checked Noun/Unit.mdx, Noun/Lease.mdx and Offer/pro.mdx Version 20 would replace version 19 in Test Breaking: Unit loses building API "building" leaves every unit from /api/units SDK building leaves Unit, in leaseclock 2.0.0. Projects on ^1 stay on 1.4.0 CLI --building leaves do Unit.create and do Unit.update Changelog Marked Breaking Every unit keeps its building in its history. A version with building serves it again. Preview only, nothing is saved. ``` A breaking push asks before it saves. In a script, it exits with status 2 until it runs with `--yes`. `--json` prints the same preview as `{ version, changes: [{ surface, change, breaking }], breaking }`. ## Versions Every push that changes a file saves a version, and so does every save in the admin, such as a new post on the Posts screen. SaaS.Dev keeps every version, with its files, when it was saved and where it came from. ```bash title="Terminal" $ npx saas.dev versions VERSION SERVED IN SAVED FROM CHANGE 19 Test, Live 2026-10-25 14:10 CLI Lease gains rent 18 2026-10-23 09:32 Admin Post: How Northgate Rentals renews every lease on time 17 2026-10-20 16:05 CLI Leaseclock Pro allows 200 units ``` `pull` writes a version's files into your folder. With no number, it fetches the version Test serves, so a post your team wrote in the admin comes back into your repo: ```bash title="Terminal" npx saas.dev pull npx saas.dev pull 17 ``` `rollback` serves an earlier version again. The later versions stay saved, and records keep every value they hold, so serving version 19 again brings each lease's rent back. ```bash title="Terminal" $ npx saas.dev rollback 18 --live Live serves version 18. Version 19 stays saved, and Test still serves it. ``` A rollback changes the definition a copy serves. To reverse a change to a record, such as a renewal, use its undo in the app, the [API](https://saas.dev/docs/api.md) or the [CLI](https://saas.dev/docs/cli.md). Version numbers count changes to your definition. The `leaseclock` package keeps its own version: a change that adds publishes a minor version, and a breaking change publishes a major one, as [SDK](https://saas.dev/docs/sdk.md) describes. ## Test and Live Every product runs as two copies. Test has its own records, its own customers and Stripe's test mode, so a checkout with card `4242 4242 4242 4242` creates a subscription and charges nothing. Live has your real customers and real payments. [`npx saas.dev push`](https://saas.dev/docs/saas-dev-cli.md) reaches Test. Live moves only on `push --live` or `rollback --live`, so Test runs ahead while you check a change, and once Live serves the same version, a script that passes in Test runs unchanged in Live. `push --live` serves only a version Test already runs, and waits for your approval: it prints an approval link, and an agent or a script without a terminal waits on it. A token made with `token create --live` is the one way past it. | Surface | Works in Test with | | --- | --- | | Admin | The Test and Live switch in the admin's sidebar | | App | `test.leaseclock.com/app`, with a band across every screen: "Test data · cards are not charged" | | Site, docs and blog | `test.leaseclock.com`, or `my-product.test.saas.dev` before the product has a domain, for anyone signed in to your team | | API, CLI, SDK and MCP | A Test key, created under **Settings → API keys** while the app is switched to Test. The key decides: a Test key reaches Test, and a Live key reaches Live | Your customers reach Test only for their own workspace, with a Test key. Northgate Rentals can try a script against its Test leases, and no customer reaches Test by browsing leaseclock.com. An Offer with `status: Draft` sells in Test only, and a post with `draft: true` stays on Test, so a new plan or a post can wait there while the rest of a version goes Live. ## Push from CI Push from your repository the way you push from a terminal. This workflow previews every pull request and pushes `main` to Test: ```yaml title=".github/workflows/saas-dev.yml" on: pull_request: push: branches: [main] jobs: push: runs-on: ubuntu-latest env: SAAS_DEV_TOKEN: ${{ secrets.SAAS_DEV_TOKEN }} steps: - uses: actions/checkout@v4 - if: github.event_name == 'pull_request' run: npx saas.dev push --preview - if: github.ref == 'refs/heads/main' run: npx saas.dev push ``` Create the token with `npx saas.dev token create ci` and store it as the `SAAS_DEV_TOKEN` secret. A token pushes to Test unless it was created with `--live`, so no pull request reaches your customers. `npx saas.dev token revoke ci` ends it. ## Changes to the defaults Sign-in, the admin's lists, checkout and every other default belong to SaaS.Dev. When SaaS.Dev improves one, the improvement reaches Leaseclock on the next request, with no push and no new version. It leaves your Nouns, Verbs, API fields and SDK types as they are, so nothing a customer's script calls changes underneath it. SaaS.Dev's [changelog](/changelog) lists each improvement. A default you replace with your own component stays yours, and only the defaults you keep pick up improvements. [Defaults and overrides](https://saas.dev/docs/overrides.md) lists each one. ## Export One command exports the whole product: ```bash title="Terminal" $ npx saas.dev export Exported leaseclock.com to leaseclock-export/ ``` ```text title="leaseclock-export/" Noun/ Offer/ Post/ the definition Live serves, ready to push again pages/ views/ fields/ your own pages and components theme.json your theme versions/ every version's files live/leases.jsonl every Live record, one per line, as the API returns it live/units.jsonl live/subscriptions.jsonl live/workspaces.jsonl every customer, its members and their roles live/history.jsonl every change to every record, with who made it and from where test/ the same for Test ``` Scripts call the same export on `api.saas.dev`: ```bash title="Terminal" curl https://api.saas.dev/leaseclock.com/export \ -H "Authorization: Bearer $SAAS_DEV_TOKEN" \ -o leaseclock-export.zip ``` Your Stripe account needs no export. Its customers, subscriptions and payouts have been in your own account from the first sale. ## Questions ### What happens when two people push at once? Each push names the version it started from. When someone has pushed since, the push stops and lists the files that differ. Run `npx saas.dev pull`, merge, and push again, so nobody replaces a change they have not seen. ### Can my agent push? Yes. Your agent runs the same commands with your token, and reads the same preview and errors. Ask it to show you the preview before it runs `push --live`. ### Does a rollback undo what my customers did? No. Renewals, new units and payments stay as they are. A rollback changes the fields, Verbs and pages a copy serves, and records keep every value through it. ### How long are versions and history kept? For as long as the product exists. Deleting a record records the removal as a version, so its undo brings it back with its history. --- url: https://saas.dev/docs/saas-dev-cli md: https://saas.dev/docs/saas-dev-cli.md --- # npx saas.dev `npx saas.dev` creates your product, checks and pushes its definition, takes it Live on your domain and connects your Stripe account. It runs without an install, signs in once per machine, and every command answers the same way to you and to your coding agent. ## Start a product `init` asks for a name, signs you in on the first run, and starts your product from Leaseclock unless `--from` names another. It writes the definition files and `AGENTS.md` into a new folder, and prints the address where your copy already runs in Test. ```console title="Terminal" $ npx saas.dev init Name your product: my-product Signed in as you@example.com Created my-product in Test from leaseclock.com Noun/Lease.mdx Noun/Unit.mdx Offer/pro.mdx AGENTS.md https://my-product.test.saas.dev ``` Start building on saas.dev does the same in the browser. Your copy runs at once, and `npx saas.dev pull my-product` writes its files into a folder when you want them. ## Sign in `login` opens a browser tab on api.saas.dev, and the CLI keeps an id.org.ai token for the machine, so neither you nor your agent pastes a key. In CI, set `SAAS_DEV_TOKEN` to a token from `token create`, and the CLI reads it from the environment instead of opening a browser. A token pushes to Test unless it was created with `--live`. ```console title="Terminal" $ npx saas.dev login Signed in as you@example.com $ npx saas.dev token create ci Created the token ci. It pushes to Test. Store it as SAAS_DEV_TOKEN in your CI secrets. ``` ## Push and preview `push` checks every file in the folder and saves a new version in Test. When files break a rule, nothing is saved, and every error comes back at once with its file, line, fix and edit, so your agent corrects them in one pass. `push --fix` applies every edit, then previews. `push --preview` prints what changes on each surface and saves nothing. `push --live` serves the version Test runs to your customers once you approve it: it prints an approval link, and Live changes when you open it and approve. Only a token made with `token create --live` skips the link. ```console title="Terminal" $ npx saas.dev push 422 Lease.mdx line 6: field "EndsOn" must start lower-case. Rename it to "endsOn". 422 pro.mdx line 4: limits names "Units", and no Noun has that name. Use "Unit". $ npx saas.dev push --fix Pushed version 2 · https://my-product.test.saas.dev $ npx saas.dev push --live Approve at https://api.saas.dev/approve/v2 Approved by you@example.com · Live serves version 2 Live · https://my-product.saas.dev ``` With `--json`, a push prints `{ ok, errors }` and a preview prints `{ version, changes: [{ surface, change, breaking }], breaking }`. | Exit code | When | | --- | --- | | `0` | The command did what it says | | `1` | Any error, such as a file that breaks a rule | | `2` | A breaking change without --yes | ## Go live `domain add` serves the product on your own domain and prints the one DNS record to add at your DNS host. Test then answers at `test.` on the same domain. `stripe connect` signs you in to Stripe once, for Test and Live. Payments land in your Stripe account, under your product's name. [Stripe](https://saas.dev/docs/stripe.md) covers checkout, the billing portal and refunds. ```console title="Terminal" $ npx saas.dev domain add leaseclock.com Add this record at your DNS host: CNAME leaseclock.com leaseclock.saas.dev ✓ leaseclock.com answers, with its certificate $ npx saas.dev stripe connect ✓ Stripe connected · acct ••••7c1e ``` ## Versions and export Every push is a version. `versions` lists them with who pushed each and where it runs, `pull` writes any of them into the folder, and `rollback` serves an earlier one again as a new version. `pull` also writes `.saas/types.d.ts`, so an override that names a renamed field fails to type-check. `export` downloads the definition and every record in one file. Your Stripe account and your domain stay yours. ```console title="Terminal" $ npx saas.dev versions 19 you@example.com Oct 21 Test 18 you@example.com Oct 14 Test, Live $ npx saas.dev rollback 18 --live Live serves version 18. $ npx saas.dev export Saved leaseclock.com-2026-10-25.json ``` ## Commands | Command | What it does | | --- | --- | | `npx saas.dev init [folder] [--from ]` | Asks for a name, signs you in on first run, starts from Leaseclock unless --from names another product, writes its files and AGENTS.md, and prints its Test URL | | `npx saas.dev login` | Signs you in through the browser and stores an id.org.ai token | | `npx saas.dev pull [name] [version]` | Writes a product's definition, or an earlier version, into a folder, with .saas/types.d.ts and .saas/schema/ | | `npx saas.dev push` | Checks every file and pushes a new version to Test | | `npx saas.dev push --preview` | Checks every file and prints what changes on each surface, saving nothing | | `npx saas.dev push --live` | Pushes the version in Test to Live, after a preview | | `npx saas.dev versions` | Lists every version with who pushed it and where it runs | | `npx saas.dev rollback [--live]` | Serves an earlier version again, as a new version | | `npx saas.dev open [path]` | Opens the product in Test in the browser | | `npx saas.dev domain add ` | Serves the product on your own domain, with the DNS record to add | | `npx saas.dev stripe connect` | Connects your Stripe account, once, for Test and Live | | `npx saas.dev token create [--live]` | Creates a token for CI, which pushes to Test unless created with --live | | `npx saas.dev token revoke ` | Ends a token | | `npx saas.dev export` | Downloads the definition and every record in one file | | `npx saas.dev mcp` | Serves SaaS.Dev's MCP tools over stdio, for an agent on this machine | | Flag | What it does | | --- | --- | | `--yes` | Confirms without a prompt, for scripts and CI | | `--json` | Prints the result as JSON: a push returns { ok, errors }, a preview { version, changes, breaking } | | `--fix` | Applies every error's edit, then previews | | `--product ` | Picks the product when the folder does not say | ## Questions ### How is it different from `npx leaseclock` `npx saas.dev` is yours: it builds and ships the product. `npx leaseclock` is the product's own [CLI](https://saas.dev/docs/cli.md), generated from your definition for your team and your customers to read records and run Verbs. Nothing in it names SaaS.Dev. ### Does my agent run these commands Yes. Your agent runs the same commands with your sign-in, reads the same previews and errors, and fixes a file from the line and the fix each error names. Live still waits for you: `push --live` hands your agent an approval link to send you. [Build with your agent](https://saas.dev/docs/agents.md) connects it. --- url: https://saas.dev/docs/errors md: https://saas.dev/docs/errors.md --- # Errors Every error SaaS.Dev returns has a code, the line it refers to and the fix. Each code links from the error itself, so you or your agent land on the fix in one click. Errors come from two places. [`npx saas.dev`](https://saas.dev/docs/saas-dev-cli.md) and `api.saas.dev` check your definition files and your account, and their errors link here. Your product's API, CLI, SDK and MCP server return the request codes to your customers in your product's names, linked to your own errors page, such as [leaseclock.com/docs/errors](https://leaseclock.com/docs/errors). ## Every code | Status | Code | When | | --- | --- | --- | | 422 | [`invalid_yaml`](https://saas.dev/docs/errors.md#invalid_yaml) | The file does not parse | | 422 | [`unknown_key`](https://saas.dev/docs/errors.md#unknown_key) | A key its place does not take | | 422 | [`missing_key`](https://saas.dev/docs/errors.md#missing_key) | A key that must be there is not | | 422 | [`invalid_name`](https://saas.dev/docs/errors.md#invalid_name) | A name with the wrong shape | | 422 | [`duplicate_name`](https://saas.dev/docs/errors.md#duplicate_name) | Two things share one name | | 422 | [`unknown_type`](https://saas.dev/docs/errors.md#unknown_type) | A field's type is not one SaaS.Dev has | | 422 | [`unknown_noun`](https://saas.dev/docs/errors.md#unknown_noun) | A link or a limit names a Noun the product does not have | | 422 | [`unknown_state`](https://saas.dev/docs/errors.md#unknown_state) | A Verb's from or to names a state status does not list | | 422 | [`invalid_value`](https://saas.dev/docs/errors.md#invalid_value) | A key holds a value it cannot take | | 409 | [`stripe_not_connected`](https://saas.dev/docs/errors.md#stripe_not_connected) | A push to Live sells an Offer, and no Stripe account is connected | | 409 | [`version_changed`](https://saas.dev/docs/errors.md#version_changed) | Another push landed while this one waited | | 400 | [`invalid_json`](https://saas.dev/docs/errors.md#invalid_json) | The body is not valid JSON | | 400 | [`idempotency_key_required`](https://saas.dev/docs/errors.md#idempotency_key_required) | A Verb POST has no Idempotency-Key | | 401 | [`invalid_key`](https://saas.dev/docs/errors.md#invalid_key) | The key is missing, revoked or mistyped | | 402 | [`limit_reached`](https://saas.dev/docs/errors.md#limit_reached) | The write would pass a limit of the customer's Offer | | 403 | [`not_allowed`](https://saas.dev/docs/errors.md#not_allowed) | The key's role cannot make this call | | 404 | [`not_found`](https://saas.dev/docs/errors.md#not_found) | No record with that id in the key's workspace | | 409 | [`wrong_state`](https://saas.dev/docs/errors.md#wrong_state) | The Verb does not run from the record's current state | | 409 | [`idempotency_key_reused`](https://saas.dev/docs/errors.md#idempotency_key_reused) | The Idempotency-Key was used for a different request | | 413 | [`body_too_large`](https://saas.dev/docs/errors.md#body_too_large) | The body is over 1 MB | | 422 | [`invalid_field`](https://saas.dev/docs/errors.md#invalid_field) | A field is missing, unknown or the wrong type | | 422 | [`field_set_by_verb`](https://saas.dev/docs/errors.md#field_set_by_verb) | A PATCH sends a field that only a Verb changes | | 422 | [`verb_stopped`](https://saas.dev/docs/errors.md#verb_stopped) | The Verb's own function threw | | 429 | [`rate_limited`](https://saas.dev/docs/errors.md#rate_limited) | The key sent more requests a second than its limit | | 500 | [`server_error`](https://saas.dev/docs/errors.md#server_error) | Something failed inside SaaS.Dev | ## What an error looks like Every error is the same JSON object, from `api.saas.dev` and from your product's API alike: ```json title="422 Unprocessable Content" { "error": { "code": "invalid_name", "message": "field \"EndsOn\" must start lower-case.", "file": "Noun/Lease.mdx", "line": 6, "fix": "Rename it to \"endsOn\".", "docs": "https://saas.dev/docs/errors#invalid_name" } } ``` | Field | What it holds | | --- | --- | | `code` | The code, in snake_case. It never changes, so branch on it | | `message` | What is wrong, in one sentence for a person or an agent | | `fix` | The change that clears the error | | `docs` | The code's entry: on saas.dev for errors about your definition and account, on your own docs for errors your customers get | | `file`, `line` | The file and line a definition error refers to | | `field` | The field a request error refers to | Some codes add what the fix needs, such as `limit` and `offer` on `limit_reached`. `npx saas.dev` and your product's own CLI print the same error on one line, with the status, the place, the message and the fix, and exit with status 1 so a CI step fails on it: ```console title="Terminal" $ npx saas.dev push 422 Lease.mdx line 6: field "EndsOn" must start lower-case. Rename it to "endsOn". 422 pro.mdx line 4: limits names "Units", and no Noun has that name. Use "Unit". ``` ## A push returns every error A push checks every file and returns every error at once, so your agent fixes them in one pass. Nothing is saved until the push passes. With `--json`, `npx saas.dev push` prints the same body `api.saas.dev` returns: ```json title="npx saas.dev push --json" { "ok": false, "errors": [ { "code": "invalid_name", "file": "Noun/Lease.mdx", "line": 6, "message": "field \"EndsOn\" must start lower-case.", "fix": "Rename it to \"endsOn\".", "edit": { "file": "Noun/Lease.mdx", "line": 6, "from": "EndsOn", "to": "endsOn" }, "docs": "https://saas.dev/docs/errors#invalid_name" }, { "code": "unknown_noun", "file": "Offer/pro.mdx", "line": 4, "message": "limits names \"Units\", and no Noun has that name.", "fix": "Use \"Unit\".", "edit": { "file": "Offer/pro.mdx", "line": 4, "from": "Units", "to": "Unit" }, "docs": "https://saas.dev/docs/errors#unknown_noun" } ] } ``` `edit` is the fix as a change to one line. `npx saas.dev push --fix` applies every `edit`, then previews. The command exits with status 1 on any error, and with status 2 when a push holds a breaking change and runs without `--yes`. Your SDK throws it as a typed error with `status`, `code`, `message`, `fix` and `docs`. Your MCP server returns it as a tool error whose text holds the message, the fix and the link, so a customer's agent corrects the call and tries again. ## Handle errors in code Branch on `code`. A message can be reworded to read better, while a code stays fixed. Treat a code your client does not know by its HTTP status. ```ts title="renew.ts" import { Leaseclock, LeaseclockError } from 'leaseclock' const $ = new Leaseclock({ token: process.env.LEASECLOCK_TOKEN }) try { await $.Lease.renew('5a', { endsOn: '2027-09-30' }) } catch (err) { if (!(err instanceof LeaseclockError)) throw err if (err.code === 'wrong_state') { // Hannah Cho's lease on 5A has ended, so she signs a new one. await $.Lease.create({ unit: '5a', renter: 'hannah.cho@example.com', endsOn: '2027-09-30' }) } else { console.error(`${err.message} ${err.fix} ${err.docs}`) throw err } } ``` Retry `rate_limited` and `server_error`. Every other code returns the same answer until the request, the record or the plan changes. The SDK and the CLI retry both for you, waiting as long as `Retry-After` says and resending the same `Idempotency-Key`, so a retried Verb runs once. ## Definition errors `npx saas.dev push` checks every file before it saves anything. A file that breaks a rule returns 422 with the file, the line and the fix, and Test stays at its last version. The push reports every broken line in one response, so your agent fixes them all in one pass. [Constraints](https://saas.dev/docs/constraints.md) explains why each rule exists. ### `invalid_yaml` The file does not parse, so nothing else in it can be checked. The usual cause is a brace, bracket or quote left open. ```text 422 Lease.mdx line 7: status is missing its closing "}". Add "}" at the end of the line. ``` Close it, and push again. The next push checks the rest of the file. ### `unknown_key` A key that its place does not take. A Noun takes `name`, `fields` and `verbs`, a field takes the keys of its type, and an Offer's price takes `amount`, `currency` and `unit`. ```text 422 Unit.mdx line 3: a Noun takes name, fields and verbs. Rename "properties" to "fields". 422 pro.mdx line 3: price has no key "interval". Use "unit: month". ``` The fix names the key that does the job. Every surface reads the same keys, so a key outside them would reach one surface and miss the rest. ### `missing_key` A key that must be there is not. Every field needs a `type`, every Noun a `name`, and every Offer a `price`. ```text 422 Unit.mdx line 4: field "label" has no type. Add one, such as "type: text". ``` Add the key on the line the error names. ### `invalid_name` A name with the wrong shape. A Noun's name starts upper-case, and field keys and Verb names are camelCase and start lower-case, because each name becomes a URL, a JSON key, an SDK property and a CLI flag. ```text 422 Lease.mdx line 2: name "lease" must start upper-case. Rename it to "Lease". 422 Lease.mdx line 6: field "EndsOn" must start lower-case. Rename it to "endsOn". ``` Rename it as the fix says. The App's labels come from the same name, so `endsOn` still heads its column "Ends on". ### `duplicate_name` Two Nouns share a name, or one Noun has two fields or Verbs with the same name. Each name is an address on every surface, so it can point at one thing only. ```text 422 Agreement.mdx line 2: name "Lease" is taken by Lease.mdx. Rename one of them. ``` Rename one, or merge the two files if they describe the same thing. ### `unknown_type` A field's `type` is not one SaaS.Dev has. [Nouns](https://saas.dev/docs/nouns.md) lists every field type. ```text 422 Unit.mdx line 6: field "bedrooms" has no type "integer". Use "number". ``` Use the type the fix names. It is the closest one, and it brings its own input, check and JSON schema. ### `unknown_noun` A `link` field's `target`, or an Offer's `limits`, names a Noun your product does not have. Names are singular and match the Noun's `name` exactly. ```text 422 Lease.mdx line 4: field "unit" links to "Units", and no Noun has that name. Use "Unit". 422 pro.mdx line 4: limits names "Units", and no Noun has that name. Use "Unit". ``` Use the Noun's name as its file writes it, or add the Noun if it is missing. ### `unknown_state` A Verb's `from` or `to` names a state that the Noun's `status` field does not list. ```text 422 Lease.mdx line 12: renew runs from "Active", which is not a status of Lease. Use "active". 422 Lease.mdx line 13: verb "end" moves to "closed", which is not a status. Use "ended", or add "closed" to status. ``` Use one of the `status` options, or add the new state to `status` in the same push. ### `invalid_value` A key holds a value it cannot take, such as a billing unit Stripe does not sell or a limit that is not a whole number. ```text 422 pro.mdx line 3: unit "monthly" is not a billing unit. Use "month" or "year". ``` Use a value the fix names. ## Push errors These come from `npx saas.dev push` when every file is valid and the push still cannot land. ### `stripe_not_connected` A push to Live holds an Offer, and the product has no Stripe account connected. In Test, checkout runs on a Stripe test account SaaS.Dev opens for you, and Live sells through your own. ```bash title="Terminal" npx saas.dev push --live # 409 Leaseclock Pro sells in Live, and leaseclock.com has no Stripe account. Run "npx saas.dev stripe connect". ``` Connect your Stripe account once, and push again. [Stripe](https://saas.dev/docs/stripe.md) walks through it. ### `version_changed` Another push landed between the change you were shown and your yes, so the change you approved is out of date. It happens when you and your agent, or two agents, push to the same product at once. ```text 409 Test moved to version 4 while this push waited. Push again to see both changes. ``` Push again. The new preview shows the other push's change beside yours. ## Request errors Your product's API returns these to anyone who calls it, and the CLI, SDK and MCP server pass them on unchanged. `api.saas.dev` returns the same codes for calls about your own products. A preview, a GET on a Verb's URL, returns the error the POST would, without changing anything. ### `invalid_json` **400.** The body is not valid JSON. From a shell, the cause is nearly always quoting. Wrap the body in single quotes, send `Content-Type: application/json`, and check the position the message names. ### `idempotency_key_required` **400.** A Verb's POST arrived with no `Idempotency-Key` header. The key is what lets a retry run the Verb once. The SDK, the CLI and the MCP server send one for you. From curl, add `-H "Idempotency-Key: $(uuidgen)"`, with a new key for each new run and the same key for a retry. ### `invalid_key` **401.** The token is missing, revoked or mistyped. On your product's API, create a key in the app under **Settings → API keys**, and read it from the environment as `$LEASECLOCK_TOKEN`. On `api.saas.dev`, run `npx saas.dev login`, or set `SAAS_DEV_TOKEN` in CI to a token from id.org.ai. ### `limit_reached` **402.** The write would take a workspace past a limit in its Offer. Leaseclock Pro holds 200 Units, and Harbor Point Homes, another Leaseclock customer, already has 200: ```json title="402 Payment Required" { "error": { "code": "limit_reached", "message": "Leaseclock Pro includes 200 Units, and this workspace holds 200.", "fix": "Delete a Unit this workspace no longer manages, or move to a plan with more Units.", "noun": "Unit", "limit": 200, "offer": "leaseclock.com/offers/pro", "docs": "https://leaseclock.com/docs/errors#limit_reached" } } ``` Nothing is saved, and an import that would pass the limit is rejected whole. To sell more room, raise `limits` in the Offer, or add a larger Offer such as `Offer/team.mdx`, and the upgrade prompt in the app offers it. [Offers](https://saas.dev/docs/offers.md) covers limits. ### `not_allowed` **403.** The key's role cannot make the call. A Viewer at Northgate Rentals can read leases, and renewing one needs a Member. ```text 403 A Viewer cannot run renew. Ask a workspace Admin for the Member role. ``` An Admin changes the role in the app under **Settings → Members**. Every surface checks the same rule, so a call the app would block is blocked in the API, the CLI and the agent's tools too. ### `not_found` **404.** No record has that id in the key's workspace. A record in another customer's workspace answers the same way, so an id reveals nothing about anyone else's records. List the records to find the id, as in `npx leaseclock show leases`. A record created in Test lives only in Test, so reach it with a Test key. ### `wrong_state` **409.** The Verb does not run from the record's current state. Hannah Cho's lease on 5A ended on September 30, 2026, and `renew` runs only `from: [active]`: ```json title="409 Conflict" { "error": { "code": "wrong_state", "message": "Lease 5a is ended, and renew runs only on an active lease.", "fix": "Renew an active lease, or create a new lease for Unit 5A.", "docs": "https://leaseclock.com/docs/errors#wrong_state" } } ``` The App shows the same reason beside a disabled Renew button. Run a Verb the state allows, or create a new record. ### `idempotency_key_reused` **409.** The `Idempotency-Key` was already used for a different request. A key stands for one request, so a retry with it returns the first response, and a new request under it is stopped before it runs. Send a new key for each new request. ### `body_too_large` **413.** The body is over 1 MB. Send the records in smaller batches. Each batch is checked and saved on its own. ### `invalid_field` **422.** A field is missing, unknown to the Noun, or the wrong type. The error names the field, and for an unknown one, the closest field the Noun has: ```json title="422 Unprocessable Content" { "error": { "code": "invalid_field", "field": "endsOn", "message": "endsOn must be a date, and \"next year\" is not one.", "fix": "Send a date such as 2027-11-30.", "docs": "https://leaseclock.com/docs/errors#invalid_field" } } ``` Send the field as its type reads in JSON: a date as `2027-11-30`, a link as the linked record's id, a `choice` as its option's key. The [API](https://saas.dev/docs/api.md) lists each type's form. ### `field_set_by_verb` **422.** A PATCH sends a field that only a Verb changes. Lease's `status` moves only through `end`, so a PATCH with `"status": "ended"` stops here, and the fix names `POST /api/leases/2a/end`. Run the Verb. It keeps the change in the record's history, with an undo. ### `verb_stopped` **422.** The Verb's own function threw. The message is the text your function threw, word for word: ```text 422 endsOn must be later ``` The person or agent changes the input and runs it again. To change the words they read, change the `throw` in the Verb's `fn`. [Verbs](https://saas.dev/docs/verbs.md) shows a function that throws. ### `rate_limited` **429.** The key sent more requests a second than its limit: 100 in Live and 25 in Test. `Retry-After` gives the seconds to wait, and every response carries `RateLimit-Remaining`, so a script slows down before it is stopped. Wait and retry. The SDK and the CLI do both for you. ### `server_error` **500.** Something failed inside SaaS.Dev. Every write either completes or leaves the record as it was, so nothing half-saves. Your customers read Leaseclock's name in the message, and nothing in it names SaaS.Dev. Retry with the same `Idempotency-Key`, and the request runs once. ## Your customers' errors page Every product gets its own errors page with every request code, in its own names and on its own domain. Leaseclock's is [leaseclock.com/docs/errors](https://leaseclock.com/docs/errors). When Priya Shah's import script at Northgate Rentals passes 200 Units, the error links her to Leaseclock's entry for `limit_reached`, and the fix there names Leaseclock Pro and its pricing page. Nothing on it names SaaS.Dev.