Docs

Form schema

Each form is a TypeScript file in mirio/forms/<slug>.ts that exports the result of defineForm. defineForm validates the schema at module load, so a bad schema fails fast in the site's own build and in mirio push.

import { defineForm } from '@trymirio/mirio/schema';

export const contact = defineForm({
  slug: 'contact',            // lowercase letters, numbers, dashes; stable; used in the URL
  name: 'Contact',
  description: 'Shown in the dashboard only',
  pages: [ /* one or more pages */ ],
  settings: { /* optional */ },
});

Pages

A form is a list of pages. A classic form has one page; a multi-step form has many. Pages are the unit for partial saves and step completion.

pages: [
  { key: 'about', title: 'About you', description: 'Takes a minute', fields: [ /* … */ ] },
  { key: 'project', title: 'Your project', fields: [ /* … */ ] },
]

key, title and description are optional. Every page needs at least one field.

Fields

Every field has a stable key (unique across all pages), a type, and a label. Optional on every field: help, placeholder, required, renamedFrom, defaultValue.

Type Value stored Constraints
short_text string, trimmed minLength, maxLength (default 500), pattern (regex source)
long_text string, trimmed minLength, maxLength (default 10000), pattern
email string, lowercased
phone E.164 string, e.g. +15555550123 defaultCountry (ISO-2, default US) for numbers typed without a country code
url normalized URL; https:// added when no scheme
number number min, max, integer
single_choice one option value options (required)
multiple_choice array of option values options (required), minSelected, maxSelected
dropdown one option value options (required)
yes_no boolean options (optional, exactly two: the labels shown for yes and no)
rating integer min..max min (default 1, may be 0), max (default 5, 2 to 10)
date YYYY-MM-DD min, max as ISO dates
consent boolean; required means it must be true
hidden string; not rendered defaultValue

Also on every field: autocomplete (the HTML autocomplete token: name, given-name, email, tel, organization, url), prefill: { query: 'utm_term' } to take the initial value from a URL parameter (hidden fields default to their own key; when the form's page lacks it, the visit's landing page is used, see hidden fields), and showWhen for conditional logic.

There is no separate name field. Use short_text with autocomplete: 'name' (or given-name and family-name for two fields); browsers then autofill it, and schedule success links pick it up.

options accept strings or { value, label } objects:

{ key: 'plan', type: 'dropdown', label: 'Plan', options: ['Free', { value: 'pro', label: 'Pro ($29/mo)' }] }

Renaming a field

Keys are what the site code and the API use, but submissions are stored by a stable field id. Declare the old key so the data stays together:

{ key: 'full_name', type: 'short_text', label: 'Full name', renamedFrom: ['name'] }

The submissions table shows one column, and the version diff reports a rename rather than a removal plus an addition.

Fields Mirio refuses

Keys or labels that look like passwords, card numbers, CVV codes or social security numbers are rejected by defineForm and by push. Mirio is not a place for secrets. Use a payment provider for cards.

Settings

The schema file holds what the visitor sees and what the form accepts:

settings: {
  success: { type: 'message', title: 'Thanks', message: 'We will be in touch.' },
  // or: success: { type: 'redirect', url: 'https://example.com/thanks', prefill: { ref: 'email' } },
  // or: success: { type: 'schedule', url: 'https://calendly.com/versive/demo' },   // Calendly or Cal.com, name and email prefilled
  submitLabel: 'Request a demo',
  agents: 'accept',                     // 'accept' (default; declared agents are labeled) | 'block'
  agentNotes: 'What this form is for and what good answers look like',
  turnstileSiteKey: '0x4AAAAAAA…',      // optional: your Cloudflare Turnstile widget; Mirio also needs its secret key (see Spam)
}

schedule sends the visitor to a Calendly or Cal.com page with name, email and any utm_* parameters filled in, so the demo is booked in the same flow. The visitor's browser runs this code, so success from the file is what they see. Settings are applied at registration and are not part of the schema version. Changing a label, option or constraint is; every distinct schema becomes a new version with a visible diff. agents also accepts the old 'allow' and 'flag', both meaning 'accept'.

What lives in Mirio instead

Everything the team changes without a deploy lives in Mirio (the dashboard, the CLI or the API), not in the file:

Setting Where
Allowed domains (per site) npx mirio domains add example.com, dashboard: Sites → the site → Domains
Notifications and integrations (per form) npx mirio destinations add email|slack|webhook|postgres … --form <slug> or --all-forms; email goes to workspace members only. See Destinations.
Triage: ideal response, summary, categories, on/off npx mirio review <slug> --ideal "…". See AI review.
Turnstile secret key (per site) npx mirio turnstile set <secret key>. See Turnstile.

settings.notifyEmails, settings.allowedOrigins and settings.review still validate for one release. The first push imports notifyEmails (member addresses only) and review when Mirio has nothing set for that form; per-form allowedOrigins are dropped (domains belong to the site). mirio status and the dev server print one line per schema that still has them; remove them from the file.

Versions

mirio push computes a hash of the canonical schema (pages, fields and their properties, not settings). A new hash becomes a new version. Submissions record the version they were made against. If a form is submitted with a hash that was never pushed, the submission is accepted, validated against the latest pushed version, flagged unregistered_schema, and the owner gets one email. Run mirio push to clear the flag for future submissions.