Form schema
Each form is a TypeScript file in kibble/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 kibble push.
import { defineForm } from 'kibbleforms/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 Kibble refuses
Keys or labels that look like passwords, card numbers, CVV codes or social security numbers are rejected by defineForm and by push. Kibble 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; Kibble 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 Kibble instead
Everything the team changes without a deploy lives in Kibble (the dashboard, the CLI or the API), not in the file:
| Setting | Where |
|---|---|
| Allowed domains (per site) | npx kibble domains add example.com, dashboard: Sites → the site → Domains |
| Notifications and integrations (per form) | npx kibble 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 kibble review <slug> --ideal "…". See AI review. |
| Turnstile secret key (per site) | npx kibble 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 Kibble has nothing set for that form; per-form allowedOrigins are dropped (domains belong to the site). kibble status and the dev server print one line per schema that still has them; remove them from the file.
Versions
kibble 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 kibble push to clear the flag for future submissions.