smithable.yml — spec version 0.1
smithable.yml describes what an application does, in technology-neutral terms. This page is the complete reference for version 0.1, together with its companions smithable.auth.yml (required) and smithable.content.yml (optional), and smithable.ux.yml (optional). Later versions add more.
Check a spec with smithable validate. For autocomplete and checking in your editor (VS Code with the YAML extension, or any editor using yaml-language-server), start the file with:
# yaml-language-server: $schema=https://docs.smithable.ai/schema/smithable-0.1.json
The JSON Schema covers smithable.yml; the companions are checked by smithable validate.
Example
smithable: 0.1
app:
name: Acme CRM
description: Lightweight CRM for a small B2B sales team.
currency: EUR
timezone: Europe/Mariehamn
models:
Customer:
description: A company we have a commercial relationship with.
fields:
name: string!
email: email unique
segment: smb | enterprise | government
creditLimit:
type: money
min: 0
pages:
customers:
intent: Find a customer quickly and keep their details current.
crud: Customer
navigation: [customers]
Top level
| Key | Required | Meaning |
|---|---|---|
smithable |
yes | The format version: 0.1. Always the first key |
app |
yes | The application as a whole |
models |
no | The data the application stores |
actions |
no | Operations beyond create, edit and delete, implemented as custom code |
pages |
no | The application's pages |
navigation |
no | Menus. Defaults to one menu with every page, in declaration order |
app
| Key | Required | Meaning |
|---|---|---|
name |
yes | The application's name |
currency |
no | ISO 4217 code, e.g. EUR. Used for money fields |
timezone |
no | IANA time zone, e.g. Europe/Mariehamn |
Plus the semantic text keys.
models
Model names are PascalCase (Customer, InvoiceLine). Each model has fields (at least one) and may have semantic text.
Every model automatically has id, owner (the user who created the record, when users sign in), createdAt and updatedAt. These names cannot be declared, and no model may be called User: that is the people who sign in.
| Key | Meaning |
|---|---|
fields |
The fields (below) |
ownedBy |
A User field whose user owns the record instead of its creator, e.g. ownedBy: accountManager. Left empty on a new record, it is the creator |
access |
Exceptions to the default access (see Access) |
capacity |
At most so many records per referenced record, e.g. a booking's places (see Capacity) |
Fields
Field names are camelCase (email, creditLimit). A field is written in the short form or the long form.
Short form: <type>[!] [unique] [= default]
| Example | Meaning |
|---|---|
name: string |
Optional text |
name: string! |
Required text (! = required) |
email: email unique |
No two records may have the same email |
capacity: int! = 12 |
Required whole number, default 12 |
status: draft | sent | paid |
One of these values. The first value is the default |
Long form, for anything more:
level:
type: beginner | allLevels | advanced
default: allLevels # a default other than the first value
rating:
type: int
required: true
min: 1
max: 5
default: 3
description: Stars from one to five.
priority:
type: low | high
default: none # stays empty until someone chooses
| Key | Meaning |
|---|---|
type |
A type, optionally with !, or choices (a | b | c) |
required |
true or false. Same as ! |
unique |
true or false |
default |
A value of the field's type, or a formula like TODAY(). For choices, none means no default |
formula |
Makes the field computed: its value comes from a formula and is not entered |
examples |
Inputs and expected results of the formula; each becomes a test |
min, max |
Bounds for int, decimal and money; for string and text, the length in characters (by default at most 200 for string and 10,000 for text) |
Plus the semantic text keys.
A choice field with a default always has a value, so it counts as required. A choice field with default: none is optional unless required: true.
Types
| Type | Holds | Default example |
|---|---|---|
string |
Short text | = Unnamed |
text |
Long text | |
markdown |
Long text with formatting (headings, lists, links, emphasis, quotes), written as Markdown and shown formatted; raw HTML in it is shown as text | |
int |
Whole number | = 60 |
decimal |
Number with decimals | = 0.5 |
money |
Amount in the app's currency | = 9.90 |
bool |
Yes or no | = true |
date |
Calendar date | = 2026-09-01 |
datetime |
Date and time | = 2026-09-01T09:30 |
email |
Email address | |
phone |
Phone number | |
url |
Web address | = "https://smithable.ai" |
picture |
A stored picture with its description (see Pictures); no default, never unique | |
pictures |
Up to ten stored pictures, the first the cover (see Pictures); no default, never unique | |
a | b | c |
One of the listed values (camelCase) | first value |
Customer, Customer! |
A reference to one record of another model (see Relations) | |
Order[] |
The records of another model that refer to this one | |
User, User! |
One of the people who sign in (see smithable.auth.yml) |
Relations
A field whose type is a model name refers to records of that model:
models:
Customer:
fields:
name: string!
orders: Order[] # every order that refers to this customer
Order:
fields:
number: string! unique
customer: Customer! # each order belongs to exactly one customer
customer: Customer!is a reference. Each order stores which customer it belongs to (in acustomerIdcolumn);!makes it required, anduniqueallows each customer at most once. Forms show a list to choose from, and tables show the related record's name.orders: Order[]is the other direction. It stores nothing: it lists the orders whosecustomeris this customer, and the customer's page shows them. The other model must have exactly one reference back, so it is always clear which records are listed.- A required reference prevents deleting the referenced record while it is in use (a customer with orders); an optional one is cleared instead.
- A model may refer to itself, e.g.
reportsTo: Employee.
Pictures
models:
Instructor:
fields:
name: string!
portrait: picture
- A picture is chosen in the record's form. The browser scales it to at most 1600 px on the long side and encodes it as WebP before sending, so a phone's photo never travels whole; the server checks the file again (WebP by its header, at most 1 MB, its size read from the file) and stores it under
DATA_DIR/uploads/, whichsmithable backupcopies with the database. The record keeps{ file, alt, width, height }; the description (alt) is what those who cannot see the picture get, and lists show a thumbnail. - A picture is shown at
/files/<model>/<id>/<field>, loaded through the model's data function, so it can be seen exactly when its record can: a draft's cover is as private as the draft. Replacing or removing a picture removes its file when no other record shows it; deleting the record does the same. Whoever may add or change the model's records may send one; sending counts against the app's write limits. picturesholds up to ten pictures on one record, chosen in the form as a strip (add several at once, remove, reorder); the first is the cover that cards and tables show, and the record page shows them all, each opening large.- A picture field cannot be
unique, have a default, or appear in formulas, searches, dashboards or calendars. Sample data uses the starter's sample pictures (public/images/samples/<n>.webp) when it has any, one to three for apicturesfield; a sample file gives several as a list.
Formulas
Computed fields and defaults use spreadsheet-style formulas:
Order:
fields:
total: money!
shippingMethod: standard | express
shippingCost:
type: money
formula: IF(AND(shippingMethod = "standard", total > 100), 0, IF(shippingMethod = "express", 15, 5))
examples:
- input: { total: 150, shippingMethod: standard }
expected: 0
Invoice:
fields:
issuedOn: date! = TODAY()
dueOn:
type: date!
default: DATEADD(issuedOn, 30, "day")
lines: InvoiceLine[]
total:
type: money
formula: SUM(lines.amount)
- Values: numbers, text in double quotes (
"standard"),TRUE/FALSE, and the fields of the same record by name. Money is in whole currency units (100is 100 EUR). Empty fields count as0or"", like blank cells. - Operators:
+ - * /,&joins text, comparisons= <> < > <= >=. - Functions:
IF,AND,OR,NOT,ROUND,ABS,MIN,MAX,PMT(rate per period, periods, amount)(a loan's payment per period),TODAY,NOW(the date and time, for date-and-time fields),DATEADD(date, n, "day" | "month" | "year"),DAYS(end, start),CONCAT. - Over a list of related records:
SUM(lines.amount),AVG,MIN,MAX,COUNT(lines),COUNTIF(bookings.status, "confirmed"). - A computed field is shown everywhere (forms show it read-only and update it as you type) but never entered. It cannot be required, unique or have a default.
- A formula default is filled in for new records, and can be changed.
- Formulas are checked when the spec is validated: unknown fields, wrong kinds of values, choice values that do not exist, and formulas that depend on themselves are reported. There are no user-defined functions or loops: anything beyond a spreadsheet cell is custom code.
pages
Page names are camelCase or kebab-case (customers, my-bookings). Each page uses one pattern:
- crud: a list, detail view, and create/edit/delete for one model;
- landing: a page for visitors, built from sections;
- dashboard: numbers, charts by month and the latest records;
- settings: the form for the viewer's one record of a model;
- onboarding: that record filled in steps, the first time someone signs in;
- users: who has access: invite people by email with a role, change roles, remove users;
- data: an administrator's view of the app's records, every model or some;
- calendar: a model's records by week or month;
- articles: a model's records as articles: a blog, news or a knowledge base;
- calculator: inputs and results computed as you type, stored nowhere;
- custom: a page of your own, written in code (see Pages and sections of your own).
| Key | Required | Meaning |
|---|---|---|
crud, landing, dashboard, settings, onboarding, users, data, calendar, articles, calculator or custom |
one of them | The pattern (see below) |
route |
no | Defaults to / plus the page name in kebab-case (classTypes → /class-types). /health, /sign-in and /api/… are reserved |
actions |
no | Crud pages: actions offered as buttons, e.g. [mergeCustomers] |
access |
no | Only these roles may open the page, e.g. [admin] (see Access) |
filters |
no | Crud pages: fields the list can be filtered by, e.g. [category, price, available]: a choice or a reference becomes chips (a select when there are more than eight values), a number, amount or date a range with "from" and "to", yes/no a toggle. Free text is searched, not filtered; pictures never; people who sign in are not filters, but owner gives signed-in viewers a "Mine" toggle. The state lives in the address (?category=furniture&price=100..500), so a filtered view can be linked; the query applies filters, search and sort together, within what the viewer may read. On phones the filters fold behind a "Filters" button |
sortable |
no | Crud pages: fields the visitor may sort the list by, either way, from a "Sort by" control, e.g. [price, createdAt]; smithable.ux.yml's table.sort stays the order until someone chooses. Not pictures, long text, lists of records or calculated fields (the query sorts by a stored column) |
mine |
no | Crud pages: true makes the page list only the viewer's own records (my items, my orders), with the same form to add one; it needs sign-in, so it never belongs under public.pages. The generated app makes it from the next release |
favourites |
no | Crud pages: true gives each record a heart and the list a "Saved" view of the viewer's own marks (a visitor who clicks is asked to sign in; nothing is shown to others); only makes the page a list of the viewer's saved records. Each mark is the person's own (a table of its own, one row per person and record, gone with either); marking checks that the viewer may read the record. Needs sign-in: without it, nothing is generated |
contact |
no | Crud pages: a "Contact" block on the record page that mails the record's owner (contact: owner) or the user a field of the model names (contact: seller), with the record's title and link; the sender's address is the reply-to, the recipient's is never shown; nothing is stored unless { to: owner, keep: Message } names a model of its own (no links to other records) to keep a copy in. Visitors may send on a public page, otherwise signed-in viewers (a signed-in sender's name and email are filled in and locked); limited like other writes; refused when the recipient cannot be reached |
search |
no | Crud pages: fields the list can be searched by, e.g. [name, email]: a search box finds records containing the text in any of them, ignoring case. Text, email, phone, web address and choice fields, and references ([number, customer] finds orders by their customer's name) |
Plus the semantic text keys. Visible text such as titles does not belong here; it goes in smithable.content.yml.
Landing pages
pages:
home:
route: /
landing:
hero: { button: sessions, shortcuts: [sessions, blog] } # its button's page; up to four shortcuts
classes: { records: ClassType } # a model's records as cards (limit: 6 by default)
latest: { records: Item, search: true, filters: [category, price], limit: 8 } # a catalogue's door: the box and chips lead to the model's list page
features:
pricing:
testimonials:
faq:
callToAction: { button: sessions }
gallery: # pictures that open large
contact: { form: Message } # details, and a form visitors send
footer: # the page's own footer, wherever it is listed
- A records section may take
search: trueandfilters(fields of its model, as on a crud page): the box and the chips lead to the model's list page with the same address state (/?q=sofa,/?category=furniture), so a front page can be a catalogue's door without a second list. It offers what that list page reads: the box when the page hassearch, a chip row for each choice field among the page'sfilters. When the list page is public, each card opens its record. A crud page may takeroute: /, so the app starts on the list, in the site frame for visitors (with its records and its new form), and the landing page is optional. - Sections appear in the order written. A section is named after its kind (
hero,features,pricing,testimonials,faq,callToAction,contact,gallery,footer), or has any name andrecords: <Model>,calculator: { fields: … }(see Calculators) orcustom: <Component>(see Pages and sections of your own). - A hero's
shortcutsare up to four pages, shown as round icons over its lower edge, each with its page's title and its navigation icon (smithable.ux.yml). ThesetShortcutsoperation sets them. - A contact section shows an address (with a link to a map), phone, email and opening hours. With
form: <Model>it also has that model's form: visitors add a record without an account, so the model must be listed underpublic.createinsmithable.auth.yml(a warning says so) and cannot link to other records. A model of its own, likeMessagewith a name, an email and a message, read only by admins, is the usual choice. - Their texts are in
smithable.content.ymlunder the page and the section's name. Without them, the hero shows the app's name and description, and records use the model's name. Features, pricing, testimonials and questions show nothing until the content file gives them items. - A landing page has its own frame (the app's name and a way in), not the app's navigation, and is not in the default navigation. List it under
public.pagesinsmithable.auth.ymlto show it without sign-in; records cards show only what the visitor may read.
Dashboards
pages:
overview:
dashboard:
outstanding: { sum: Invoice.total, where: { status: sent } } # a number
open: { count: Invoice, where: { status: sent } }
average: { average: Invoice.total }
invoiced: { sum: Invoice.total, by: issuedOn } # by month: a bar chart
latest: { list: Invoice, limit: 5 } # the newest records
- Each tile is
count: <Model>,sum: <Model.field>,average: <Model.field>(number or amount fields),list: <Model>, orquery: <query>(a query's records). wherekeeps records whose choice fields have a value, or whose yes/no fields aretrueorfalse.bynames a date field and turns a number into a bar chart of the last six months.limitis how many records a list shows (5 by default).- The numbers are worked out from the records the viewer may read, so a dashboard follows the same access rules as the lists. Labels are in
smithable.content.yml; by default they come from the tile names.
Settings and onboarding
pages:
settings:
settings: BusinessProfile # the viewer's business details
welcome:
onboarding:
model: BusinessProfile
steps: # in order; each asks for some fields
business: [businessName, vatNumber]
payment: [iban, paymentTermDays]
- A settings page edits one record of its model: the viewer's own when the model's records are private, the app's when they are shared (the first one, when there are several). Saving the first time creates it.
- An onboarding fills that record in steps, with Back, Continue and "Do this later". Signing in opens it (unless the visitor was on their way to another page), and once the record exists it hands over to the app's first page. The steps must ask for every required field without a default.
- Step titles and texts are in
smithable.content.yml, under the page and the step's name (business: { title: Your business, text: … }); the heading is "Welcome to" the app, or the page's title. Onboarding pages have their own frame and are not in the default navigation.
Users
pages:
team:
users: manage # invite people, change roles, remove users
# access: [admin] # the default for a users page; widen it with more roles
- The page lists everyone with access and the open invitations, with their role. From it, an administrator invites an email address with a role, changes a role, resends or withdraws an invitation, and removes a user. It is for admins unless
accesssays otherwise, needs sign-in, and an app has one. - An invitation is a mail with a link to the sign-in page (also shown on the page, to send by hand). The invited person signs in with a configured provider using the invited address and has the invited role; the address decides, never the link. Invitations are valid for 14 days.
- Nobody can change their own role or remove themselves, and the last administrator stays one. A removed user's records stay without an owner; a required reference to them refuses the removal until it is reassigned.
- With
selfSignUp: falseinsmithable.auth.yml, only invited addresses and theadminscan sign in at all.
Calendars
pages:
schedule:
calendar: Session # short form: the model's one date or date-time field places records
# calendar: { model: Session, starts: startsAt, duration: classType.durationMinutes, title: classType }
- A week view (days side by side, hours down the side, overlapping records side by side), a month view one button away, and on a phone a day-by-day list. Previous, Today and Next; the week or month is in the address (
/schedule?week=2026-W40,?month=2026-10), so it can be shared. startsis the date or date-time field that places a record; it is inferred when the model has exactly one.ends(a field of the same kind) orduration(whole minutes: a field, or one reference away asreference.field) gives a record its length; without them a date-time record lasts an hour and a date one the day.titleis what a block says (a field, or a reference's title); by default the model's title field, else its first required reference's title.- Records are placed in the app's time zone (
app.timezone), never the viewer's; without one the calendar warns and uses UTC. Forms read and write date-times in that zone too. - Only the records whose start falls in the range shown are loaded, through the model's access rules, so visitors see what
public.readallows. A record opens on its crud page (or a data page showing it) for those who may open that page; a visitor gets its details in a small panel. Those who may create records click an empty hour, and the new record's form starts then. - The operation
addCalendarPage page model [starts]adds one; "add a calendar for sessions" does too, without a model.
Calculators
pages:
mortgage:
calculator:
fields:
price: { type: money, min: 0, default: 300000 }
deposit: { type: money, min: 0, default: 60000 }
years: { type: int, min: 5, max: 40, default: 25 }
rate: { type: decimal, min: 0, max: 20, default: 4.5 }
monthly: { type: money, formula: "PMT(rate / 100 / 12, years * 12, price - deposit)" }
total: { type: money, formula: monthly * years * 12 }
examples:
- input: { price: 300000, deposit: 60000, years: 25, rate: 4.5 }
expected: { monthly: 1334, total: 400200 }
fieldsare written as a model's: the entered ones are the inputs, the formula fields the results, shown beside the inputs and computed as you type. No references, lists or pictures, nothing required or unique: nothing is stored, and an empty input counts as 0 or "". A formula inside{ … }goes in quotes, since its commas would otherwise split the line.examplesbecome tests: each gives inputs and the results they must produce (several at once).- A calculator page needs no sign-in by itself (list it under
public.pagesfor visitors) and reads nothing. Its texts are keyed under the page:pages.mortgage: { title: …, results: { title: Your payment }, fields: { rate: { label: Interest rate, help: The bank's yearly rate } } }. - On a landing page, a section of any name with
calculator: { fields: … }is the same thing among the other sections; its texts aretitle,text,results(the results' heading) andfieldsunder the section. - The field operations (
addField,changeField,renameField,removeField) take a calculator page's name where they take a model's; a rename follows into its formulas and examples.addCalculatorPage mortgage price=money years=intadds one; results are added withaddFieldand a formula.
Pages and sections of your own
pages:
planner:
custom: Planner # src/custom/pages/Planner.tsx, created once
home:
landing:
hero:
repayment: { custom: RepaymentChart } # src/custom/sections/RepaymentChart.tsx, created once
- For what no pattern covers (a chart, a map, a planner, an embedded video): the value names a component, a capital letter then letters and digits. Its file is created once as a stub and then belongs to the project; Smithable never changes or removes it, also when the page or section leaves the spec.
- The page's route, access, menu entry and texts stay generated, as for any page; a section's place and texts are the landing page's (
titleandtext). The component gets them as props, typed insrc/generated/custom.ts(CustomPageProps,CustomSectionProps). - Code that reads records uses the generated data functions (
src/generated/<model>.server.ts) inside a server function, as custom queries do. addCustomPage planner Planneradds a page;addSection home repayment --custom RepaymentCharta section.
Articles
pages:
blog:
articles: Post # short form: its parts are inferred
# articles: { model: Post, title: title, body: body, picture: cover, date: publishedOn, address: address, summary: summary }
posts: { crud: Post, access: [admin] } # where staff write them
- The short form infers each part by type and name:
titlea string named title, name or headline;bodythe model's onemarkdownfield;pictureits onepicturefield;dateits onedateordatetimefield;addressauniquestring named address or slug;summarya string or text named summary or excerpt. Only title and body are required. What cannot be inferred, or is found twice, is an error, and the long form settles it. /bloglists the articles the viewer may read, newest first by the date (else by creation), 12 at a time, each with its picture, date and summary (the summary field, else the body's first paragraph)./blog/<address>shows one: its picture, title, date, the author's name when the model has a user field namedauthor, the formatted body, links to the articles before and after it, and an Edit link for those who may change it on its crud page. Without an address field, articles are at/blog/<id>.- On a public page (
public: pagesinsmithable.auth.yml) the articles have the site's frame (the landing pages'), and each article's head carries its title, summary and picture for links shared elsewhere. Signed-in pages have the app's shell. - Visitors read what the model's public read allows. With
- Post: { where: { status: published } }a draft is neither listed nor found;smithable validatewarns when a public articles page shows a model with a choice or yes/no field and nowhere. - A new record's address, left empty, is made from its title (
Yin for beginners→yin-for-beginners). A landing page's records section of the model links each card to its article. - The operation
addArticlesPage page modeladds one; "add a blog for posts" and "add a news page for posts" do too, without a model.
Data
pages:
admin:
data: all # every model; or a list, e.g. data: [Customer, Order]
# access: [admin] # the default for a data page; widen it with more roles
- An administrator's view of the app's records. The page lists each model with how many of its records the viewer may read; each model has its own list (at the page's route plus the model's name in kebab-case plural, e.g.
/admin/order-lines), newest first, searchable by its text, email, phone, web address and choice fields, showing the newest 500 with a note when there are more; and a form to create, open, change and delete a record, as on a crud page. A related record opens inside the data page. - Every read and write goes through the models' own access rules, owners, validation and hooks: widening the page's
accesslets more roles open it, never do more with the records. - The model lists' routes count as the page's: another page at
/admin/customers(or a crud pagecustomersnext to a data page at/) is aduplicate-routeerror. allis every model in the spec's order, including models added later. It is for admins unlessaccesssays otherwise, needs sign-in, and an app may have more than one (a support view with fewer models).Useris not a model: the users page covers people.- The operation
addDataPage [page] [route] [models…]adds one (admin, every model, when nothing is given).
actions
An action is something the application does beyond create, edit and delete. In 0.1 every action is custom: the spec declares what it takes and returns, and the behaviour is code.
actions:
mergeCustomers:
custom: true
input: { keep: Customer!, duplicate: Customer! }
output: { moved: int! } # optional
allow: [admin] # optional: only these roles may run it
intent: Merge a duplicate customer record into the one we keep.
rules:
- All orders of the duplicate move to the kept customer.
inputandoutputuse the field shorthand, including references (Customer!means choosing a customer). Lists,uniqueand defaults do not apply.- Smithable generates the input schema, the input and output types and a server function, and creates
src/custom/actions/merge-customers.tsonce, with the intent and rules as a guide. That file is yours: Smithable never changes it. If the action's input or output changes in the spec, code that no longer fits fails type checking. - A page lists actions to offer (
actions: [mergeCustomers]); each gets a button and a form for its input. - The action runs for signed-in users;
allownarrows that to roles. The implementation can ask who is signed in, and the generated data functions check access for that user. - Standard actions that change a record without code (
on,when,set) come in a later version. - The operation
addAction <action> [name=type…] [--output name=type,…] [--allow role,…] [--intent "…"]adds one, e.g.smithable op addAction mergeCustomers keep=Customer! duplicate=Customer! --allow admin. Builder's form is "An action", and "add an action to merge customers" needs no model.
queries
A query is records chosen or ordered by code, for what a filter cannot say:
queries:
churnRisk:
custom: true
returns: Customer[]
intent: Customers most likely to stop buying, most at risk first.
rules:
- A customer without an order in 90 days is at risk.
- Smithable generates the result's type (
src/generated/queries/churn-risk.ts) and createssrc/custom/queries/churn-risk.tsonce, as a stub that returns every record the viewer may read. That file is yours; its code starts from the data functions, which apply the access rules. - A dashboard shows a query's records with
atRisk: { query: churnRisk, limit: 5 }. smithable code query:churnRisk "<what to do>"has a model write the code (see docs/ai.md); only files insrc/custom/may change, and the type check and tests must pass.
Sections Smithable does not know: project plugins
A top-level key that no built-in section or plugin handles is an error, never ignored. smithable teach <key> has a model write a project plugin for it once: smithable/plugins/<key>/plugin.mjs, declared in smithable.context.yml (plugins: [newsletter]). After that, the section is handled without AI:
newsletter: # handled by smithable/plugins/newsletter/plugin.mjs
from: hello@stillwater.ax
lists: [classes, offers]
A plugin is one ES module without imports, exporting { key, description, schema, expand?, patch?, generate?, operations?, summarize? }, or a function of the plugin API (OperationError, didYouMean, identifier, kebab, refTo) returning it:
schema: the JSON Schema of the section (type, properties, required, additionalProperties, items, enum, pattern, minItems and uniqueItems are checked).expand(value): the ordinary spec the section stands for (models,pages,queries,actions), merged in as if written by hand, so the database, pages, forms and access checks are built as usual. Clashes with names insmithable.ymlare errors, reported at the plugin's section.patch(value): additions to entriessmithable.ymlalready has, never changes or removals:{ add: "field", model, field, type },{ add: "section", page, section, value? }on a landing page,{ add: "menu", page, menu?, position? }. A missing target or a clash is an error at the plugin's section.operations: operations on the section, shaped like Smithable's own (name,summary,schema,positional,describe,destructive,apply(editors, args, ir)); they appear insmithable op, the MCP tools, Studio and what the AI may propose. A name a built-in operation has is offered as<key>:<name>.summarize(value): the section in plain language for the Builder, one sentence per item.generate(value, ir): optional extra files, only undersrc/generated/plugins/<key>/, the same for the same input.
Capacity
models:
Session:
fields: { startsAt: datetime!, capacity: int! = 12, bookings: Booking[] }
Booking:
fields:
session: Session! unique # with private access: one booking per member and session
status: confirmed | cancelled
capacity: { per: session, max: session.capacity, where: { status: confirmed } }
peris one of the model's required references;maxa whole number, or a whole-number field of the referenced record written asreference.field;where(optional) counts only records with these choice or yes/no values.- The check happens where the record is saved, in the same transaction as the write: two people taking the last place at once cannot both get it. A record over the limit is refused ("No places left"); lowering the referenced record's number below what is taken is refused too ("3 are already taken").
- One booking per person is the field's
unique, which under private access means unique per owner.smithable validatewarns whenperis not unique ("one person can take several places"), which is sometimes intended (tickets). - The operation
setCapacity <model> <per> <max> [key=value…]sets it;setCapacity <model> noneremoves it. - Book and Cancel. With sign-in, the referenced record's page (and its panel in a calendar) shows its places ("3 of 12 places left", "Full") and a reservation block. A visitor gets "Sign in to book", which comes back to the page. A signed-in person who may create the model gets Book a place, which creates the record in one step (its other fields take their defaults). After that the block says "You're booked", with Cancel booking, which asks first and deletes the record through its data function. The places are counted over every record, and only the numbers leave the server.
- Records with a capacity get no sample data: every place starts free.
Access
Who may do what follows from defaultAccess in smithable.auth.yml; smithable.yml only lists exceptions.
shared (internal tools, teams) |
private (SaaS, consumer apps) |
|
|---|---|---|
| Read | All signed-in users | Only the owner |
| Create | Signed-in users | Signed-in users |
| Change and delete | The owner | The owner |
| Everything | Admins | Admins |
models:
Customer:
fields: { name: string!, accountManager: User }
ownedBy: accountManager
access: # replaces the default for the operations listed
write: [sales] # any salesperson may change any customer
delete: [admin]
pages:
class-types:
crud: ClassType
access: [admin] # only admins open this page
- A model's
accesshasread,create,writeanddelete, each a list of roles.ownermeans the record's owner (not forcreate). An operation listed replaces its default entirely; admins can always do everything. - Pages need sign-in unless they are public; a page's
accessnarrows it to roles. Actions: seeallowabove. - With
privateaccess,uniquemeans unique per owner: two users can both have invoice2026-001. - Everything is enforced on the server, for every read and write: a record the user may not read cannot be opened, even by guessing its id, and cannot be chosen in a form.
navigation
Either a list of pages (the main menu), or named menus:
navigation: [customers, orders]
navigation:
main: [customers, orders]
admin: [settings]
Semantic text
Any app, model, field or page may carry free text that explains meaning to AI and developers. It is never shown to visitors.
| Key | Meaning |
|---|---|
description |
What it is |
intent |
The outcome or experience it should achieve |
rules |
Domain rules as sentences. A rule does nothing by itself until something implements it; implemented rules get an id: { id: capacity, rule: … } |
smithable.content.yml: visible text
Optional, next to smithable.yml. Every text a visitor sees is derived from names (creditLimit → "Credit limit", class-types → "Class types"). This file lists only what should read differently.
smithableContent: 0.1
models:
Customer:
label: Client # singular; the plural defaults to "Clients"
pluralLabel: Clients
fields:
creditLimit:
label: Credit limit (EUR)
help: Orders are held above this amount. # shown under the form field
segment:
values: { smb: Small business } # text for a choice value
Booking: # a model with a capacity: the reservation block's words
reserve: { book: Reserve a mat, cancel: Give up my mat, full: No mats left, booked: You have a mat }
pages:
customers:
title: Our clients # page heading and menu entry
subtitle: Keep every relationship in view. # a line under the heading
menus:
admin: { label: Administration } # heading of a named menu
A landing page's sections and a dashboard's tiles have their texts under the page, by name:
pages:
home:
hero: { title: Find your calm, subtitle: Small classes for every level., button: See the schedule }
classes: { title: Our classes, text: A little time, just for you. }
features:
title: Why Stillwater
items:
- { title: Small groups, text: Never more than twelve. }
pricing:
items:
- { name: Drop-in, price: €15, period: per class, features: [Any class], button: Book }
testimonials:
items:
- { quote: My favourite hour of the week., name: Aino, role: Member }
faq:
items:
- { question: Do I need experience?, answer: No. }
callToAction: { title: Your first class is waiting, text: …, button: Book a class }
overview:
outstanding: { label: Outstanding }
The new sections' texts:
contact:
title: Questions? Write to us
address: | # one line per line; "Show on map" opens OpenStreetMap
Storagatan 1
22100 Mariehamn
phone: +358 18 123 456
email: hello@stillwater.ax
hours: |
Monday to Friday 7–21
button: Send message # the form's button
thanks: Thank you! We will get back to you within a day.
gallery:
title: Inside the studio
items:
- image: { src: images/gallery/studio.webp, alt: A yoga mat by a window }
caption: Morning light in the studio
footer:
text: Stillwater Yoga · Storagatan 1, Mariehamn
items:
- { label: Class schedule, link: /sessions } # a page's address,
- { label: Email us, link: "mailto:hello@stillwater.ax" } # or https:, mailto:, tel:
A hero can have a picture beside its text: image: { src: images/hero.webp, alt: People stretching in a yoga studio }. The file lives in the app's public/images/ (webp, avif, jpg or png, about 1200×800), which generation never touches; alt says what the picture shows, for people who cannot see it, and is required. The Booking and Invoicing starters come with one.
Every model, field, choice value, page and menu named here must exist in smithable.yml; smithable validate checks this. Semantic text (description, intent, rules) is never shown to visitors, so help text belongs here.
Libraries for custom code (src/custom/) are declared in smithable.context.yml, the one file that names technologies, with exact versions: packages: { chart.js: 4.4.1 }, or with the operation setPackage chart.js 4.4.1 (none removes one). Smithable adds them to package.json; the stack profile's own packages (React, Drizzle, …) are refused, since Smithable keeps their versions in step.
smithable.context.yml is checked against its JSON Schema (CONTEXT_SCHEMA in core): smithableContext: 0.1 and only the keys adapter, database, conventions, guidance, plugins, packages and ai (policy, allow). smithable validate reports a problem on its key, and an operation that changes the file is refused if it would leave it invalid.
The operation setText changes one text by its path, e.g. setText "All our customers" pages customers subtitle (an empty text restores the default). A list item's text is addressed by its position, e.g. pages home faq items 0 question. Studio uses it when a text is changed in place in the preview. removePage removes a page's texts along with the page, and removeSection a landing section's; addSection home gallery adds a section (with records, form or button as its options), which Builder offers as "A section on a landing page". addLandingPage home makes a landing page with a hero and a footer, at / when that is free, and lists it under public.pages unless public is false.
smithable.auth.yml: sign-in, roles and what is public
Required, next to smithable.yml. It is technology-neutral: no library names or keys (those are environment variables).
smithableAuth: 0.1
signIn:
providers: [google] # google, facebook; the order is the button order
allowedDomains: [acme.com] # optional: only these email domains may sign in
selfSignUp: false # optional: only invited people and admins (needs a users page)
defaultAccess: shared # shared | private; required with sign-in (see Access)
roles: # optional; "admin" always exists
admin: { description: Sales management. Full access. }
sales: { description: Works with customers and orders. }
defaultRole: sales # the role new users get; needed with several roles
admins: [owner@acme.com] # become admins when they first sign in
# ⚠ PUBLIC: visible to anyone on the internet, without signing in.
public:
pages: [schedule] # pages anyone may open (the sign-in page always is)
read:
- Session # models anyone may read
- Post: { where: { status: published } } # …or only the records with these values
create: [ContactMessage] # models anyone may create, e.g. a contact form
| Key | Meaning |
|---|---|
signIn |
none for an app without sign-in (then only public is reachable), or providers with optional allowedDomains and selfSignUp (true by default; false lets only invited addresses and the admins sign in, and needs a users page) |
defaultAccess |
shared or private: the default access to records (Access) |
roles |
Role names (camelCase) with an optional description. Without it: admin and member |
defaultRole |
The role new users get |
admins |
Email addresses that get the admin role when they first sign in |
public |
pages, read and create: what anyone may open, read or create without signing in. A model in read may be limited to records with certain values, Post: { where: { status: published } } (a choice field's value, or a yes/no field's true or false): visitors, and signed-in people who read only because it is public, see those records everywhere (lists, searches, cards, references), while roles that may read the model and a record's owner see the rest as before. Removing the field or the value is refused until the condition is changed. The operation setPublicWhere Post status=published sets it, and setPublicWhere Post lets visitors read every record |
smithable validate warns about risky combinations, and never changes their meaning: shared access with open sign-up and no allowedDomains (anyone with an account at the provider can sign up and read everything), and public reading of models with personal data (email, phone or User fields). Everything named in public must exist in smithable.yml, and every role used there must exist here.
In development, the sign-in page also offers signing in as any role, without contacting the provider. Production builds leave that out.
smithable.ux.yml: how the app looks
Optional, next to smithable.yml. It changes how the app looks, never what it does, and names no technology: presets and choices, not CSS.
smithableUx: 0.1
theme:
preset: corporate # modern (default) | minimal | playful | corporate
brand: { primary: "#1d4ed8", font: Inter } # optional
density: compact # comfortable | compact; the preset's own unless set (corporate is compact)
pages:
customers:
icon: users # its icon in navigation (optional)
table:
columns: [name, email, segment] # which fields the list shows, in order
below: { email: phone } # a second field under a column's value
sort: -creditLimit # the order until someone sorts; "-" for largest or latest first
view: cards # cards on wide screens too (table by default)
form:
fieldOrder: [name, email, phone] # these first; the other fields follow (or sections, not both)
sections: # groups with headings; fields in none follow at the end
- { name: contact, fields: [name, email, phone] }
- { name: terms, fields: [creditLimit, segment] }
columns: 2 # short fields two abreast on wide screens
control: { creditLimit: slider, segment: radio } # how a field is entered
mortgage:
form: { results: below } # a calculator's results under its inputs
home:
theme: { preset: playful, brand: { font: Nunito Sans } } # a landing page's own theme
sections: # a landing page's sections' looks, by section name
hero: { layout: split, image: left } # split | centered | plain | cover; the picture's side
features: { columns: 4 } # 2 | 3 | 4
classes: { layout: list } # records: cards | list | grid
testimonials: { layout: carousel } # cards | carousel | one
pricing: { highlight: Pro } # the plan drawn forward, by its name
faq: { layout: list } # accordion | list
tones: # colours of choice values, by meaning
Order.status: { shipped: success, cancelled: danger } # success | warning | danger | neutral
primaryis a colour like"#1d4ed8". The theme derives its colours from it and adjusts its lightness where needed, so text on it stays readable in light and dark mode.fontis one of Inter, Nunito Sans and Source Sans 3: open-licence fonts the app hosts itself. Without it, the preset's font is used (Inter for modern and minimal, Nunito Sans for playful, Source Sans 3 for corporate).compactmakes controls, rows and padding smaller, for data-heavy tools. Touch screens keep 44 px controls.belowshows a second field under a column's value, like the phone number under the email; the column's heading names both.themeon a landing page (only there: it has its own frame, while a list page in another theme would look like another app) changes the keys it names from the app's theme, with the same keys (preset,brand,density). Its font is installed too.setTheme --page home preset playfulsets one, andnonereturns a key to the app's.sections(a landing page only) gives a section a look. A hero issplit(the text beside its picture,image: rightorleft),centered(the text over the picture),cover(the picture across the width, the theme's primary colour washing over it behind the text) orplain; without a picture every hero is plain. Features havecolumns; a records section iscards,list(rows with a small picture) orgrid(the pictures, titled); testimonials arecards, acarouselthat scrolls sideways, oronelarge quote; a pricing section'shighlightnames the plan drawn forward; a FAQ is anaccordionor alistwith every answer shown. Removing a section removes its look.results(a calculator page'sformonly) isbeside(the default: next to the inputs on a wide screen) orbelow. A phone always shows the results under the inputs.iconis one of users, user, file, receipt, cart, package, calendar, layers, book, briefcase, building, chart, clipboard, home, mail, settings, star, tag, truck and list. Without it, one is chosen from the name of the page's model (Customer: users, Invoice: file, Session: calendar; list when nothing fits).tonesname a choice field asModel.fieldand give some of its values a meaning (success,warningordanger), which the app shows as the badge's colour. Values not listed areneutral.- Every page and field named must exist in
smithable.yml; computed fields can be columns but not in a form. Renaming or removing a field updates this file, and so does removing a choice value. controlchooses how a field is entered:slider(a number withminandmax),stepper(a whole number),radio(a choice of at most five values, or a yes/no),select(a choice or a reference),combobox(a reference),textarea(a text),switch(a yes/no);inputis the default for its kind. A control that does not fit its field is an error. A calculator page's fields take one too.sectionsgroup the form under headings, whose texts are insmithable.content.yml(pages.customers.form.sections.contact: { title: How to reach them }; the name otherwise);columns: 2puts short fields two abreast, long texts and pictures full width.sortandviewshape the list.- The operations
addTheme(a theme of the project's own,--useto apply it),setTheme,setColumns,setFieldOrder,setTone,setIcon,setBelow,setControl,setFormSections(each sectionname:field,field),setFormColumns,setView,setSortandsetSectionLookchange it. presetis one of the four built-in themes, or one of the project's own (see Themes).
Themes
A theme is data, never CSS: a font, a corner radius, a shadow and the colours the app uses, in light and dark mode. The four presets are themes Smithable ships; a project's own go in themes/<name>.yml (written through addTheme <name> --text @file [--use], refused with the file's problems) and theme.preset names either. smithable theme --from <url> [--name x] [--use] makes one from a site: a browser (Playwright) reads its background, text, primary button colour, cards, corners and font in light and dark, derives the rest, raises contrast where it would not read, and adds it with addTheme.
# themes/forest.yml
smithableTheme: 0.1
name: forest # kebab-case; the file's name when left out
font: Nunito Sans # one of the hosted fonts (Inter, Nunito Sans, Source Sans 3)
radius: 12px # corners: a length in px or rem
shadow: soft # none | soft | raised
chroma: 0.22 # optional: how much colour a brand colour keeps on this theme (0–0.4)
light:
primary: "#2f6b3a"
background: "#f6f8f4"
foreground: "#1f2a1f"
dark:
primary: "#8fd19a"
background: "#141a14"
foreground: "#e6ece4"
- Each mode needs at least
background,foregroundandprimary; the other 25 colours (card,muted,accent,border,ring,destructive,success,warning, the charts, and every-foreground) may be given or are derived the way a brand colour is: the text on a colour is whichever of dark and white reads better, muted and secondary step the background towards the foreground, borders a step further, the accent tints the background with the primary, the charts spread around the primary's hue. Colours are#rrggbboroklch(L C H). - A theme is checked like the built-ins: every pair the app puts together (text on background, card, primary, muted, accent, badges) must reach 4.5:1 and the focus ring 3:1 against the card; a theme that does not read is an error naming the pair and its ratio.
smithable themeslists the themes the ux file may name;smithable validatechecks the project's. A project theme with a built-in's name replaces it for that project. Studio shows every theme with a picture drawn from its colours.- A theme ships no CSS, components or fonts from elsewhere, so switching themes is always safe and the hosted service can offer them without vetting code.
Sample data
A project may give its models sample records in samples/<Model>.yml, beside themes/: a list of records with the model's field names as keys. pnpm db:seed (and smithable new) adds them instead of generated ones; a model without a file still gets generated records.
# samples/ClassType.yml
- name: Hatha
summary: Slow, steady postures held for a few breaths each.
level: allLevels # a choice by its value or its label
price: 16.50 # money as an amount
instructor: Emma Lindqvist # a reference by the record's title (samples/Instructor.yml)
photo: { file: hatha.webp, alt: A class stretching } # a file in public/images/samples/
- Dates are written
2026-10-05, dates with times2026-10-05 08:00. A calendar's records are moved to this week and the next, keeping their weekday and time. - A reference names the other record by its title, which needs the other model's sample file; without one, give the number of a generated record (1 for the first).
- A picture is a file name under
public/images/samples/, or{ file, alt }; withoutalt, it says what the record is. smithable validatechecks the files: an unknown model or field, a value of the wrong kind, a missing required field, a reference to a record that is not there. Lists of related records and calculated fields are not set in samples.smithable new --no-sample-datastarts with nosamples/folder and an empty database.
Not supported
Smithable specs are plain data. YAML anchors (&name), aliases (*name) and tags (!!str) are rejected, because they make specs hard to read and unsafe to edit automatically.