Smithable docsHomeGetting startedWhy SmithableQuestionsSpec referenceAI setupJSON Schema

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

Pictures

models:
  Instructor:
    fields:
      name: string!
      portrait: picture

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)

pages

Page names are camelCase or kebab-case (customers, my-bookings). Each page uses one pattern:

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

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

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]

Users

pages:
  team:
    users: manage                        # invite people, change roles, remove users
    # access: [admin]                    # the default for a users page; widen it with more roles

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 }

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 }

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

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

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

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.

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.

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:

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 } }

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

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

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"

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/

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.