kibbleforms
Inbound forms for sites that coding agents build. The form lives in your code; Kibble handles everything after submit: validation, spam protection, storage that stays coherent as the form changes, partial submissions, and delivery to email and webhooks.
One package, three things:
- React components that look finished out of the box (
kibbleforms) on top of headless primitives (kibbleforms/headless) - a client SDK (
kibbleforms/client) with idempotent, retried submits - the
kibbleCLI (npx kibble …) for provisioning, pushing schemas, testing and checking builds
npm install kibbleforms
React 18 or 19. Works in Next.js (app router), Vite, Astro + React, Remix. Docs: kibbleforms.com/docs · what changed in each release: changelog.
60-second quickstart (for agents too)
npx kibble init # sandbox project; writes kibble.json, .kibble/secret, kibble/client.ts, an AGENTS.md section,
# adds withKibble()/kibble() to your next/vite/astro config and the CSS imports, prints a claim link
// kibble/forms/waitlist.ts
import { defineForm } from 'kibbleforms/schema';
export const waitlist = defineForm({
slug: 'waitlist',
name: 'Waitlist',
pages: [
{
fields: [
{ key: 'email', type: 'email', label: 'Work email', required: true },
{ key: 'name', type: 'short_text', label: 'Name' },
{ key: 'role', type: 'dropdown', label: 'Role', options: ['Founder', 'Engineer', 'Other'] },
{ key: 'consent', type: 'consent', label: 'Email me when we launch', required: true },
],
},
],
settings: {
submitLabel: 'Join the waitlist',
success: { type: 'message', title: 'You are on the list', message: 'We will be in touch.' },
},
});
// anywhere in your site
import { Form } from 'kibbleforms';
import { kibble } from '@/kibble/client';
import { waitlist } from '@/kibble/forms/waitlist';
export function Waitlist() {
return <Form schema={waitlist} client={kibble} />;
}
npm run dev # the plugin registers a form whenever its file is saved
npx kibble test waitlist # send a test submission from localhost and confirm it landed
There is no push step to remember: withKibble(nextConfig) (kibbleforms/next) or kibble() (kibbleforms/vite, also Astro and React Router) registers forms from the dev server and runs kibble sync during builds. After the project is claimed, npx kibble env vercel (or netlify, print) gives deploy builds the key so they register their forms too; without it a build only fails when visitors would be rejected.
That is a working form on localhost with no human step. To go live, a human opens the claim link (npx kibble claim --open), signs in, and names the production domains. Until then the project is a sandbox: it accepts submissions from localhost only and its data expires after 72 hours.
Field types: short_text, long_text, email, phone, url, number, single_choice, multiple_choice, dropdown, yes_no, rating, date, consent, hidden. A form with more than one page is a multi-step form; progress is saved after each step.
Conditional logic, prefill, autocomplete, scheduling
- Conditional fields and pages:
showWhen: { field: 'role', op: 'eq', value: 'other' }(or{ all: [...] }/{ any: [...] }) on a field or a page. Hidden fields are not validated, not required, and never sent; a page with no visible fields is skipped andProgresscounts visible pages only. The hook exposesvisibleKeys,visiblePages,isFieldVisible(key),isPageVisible(i). - Prefill:
prefill: { query: 'email' }fills a field from the URL query on mount (hidden fields default to their own key);initialValuesonForm/useKibbleFormis programmatic prefill and wins. - Autocomplete:
autocomplete: 'name' | 'given-name' | 'organization' | …becomes the HTML attribute (email/tel/url fields get sensible defaults). - Scheduling:
success: { type: 'schedule', url: 'https://calendly.com/acme/demo' }redirects withnameandemailprefilled andutm_*passed through;redirectacceptsprefilltoo. The hook exposessuccessUrlandresolveSuccessUrl()for custom UIs, andnavigatelets you replacewindow.location.assign.
Agents
Forms can be filled by agents. settings.agents is 'accept' (default; declared agents are labeled) or 'block', and settings.agentNotes describes good answers; agents read GET /v1/projects/<projectId>/forms/<slug>/schema.json and submit with meta.actor: { type: 'agent', name }. In the SDK: createClient({ actor }) or useKibbleForm(schema, { actor }). A block policy returns agents_blocked; the styled Form shows the server's message and hint.
CSS setup
The components are authored with Tailwind v4 utilities that read --kb-* CSS variables. Pick one:
Tailwind v4 host (your CSS file; kibble init adds this):
@import "tailwindcss";
@import "kibbleforms/theme.css"; /* --kb-* tokens + @theme mapping (bg-kb-primary, rounded-kb, …) */
@import "kibbleforms/themes/shadcn.css"; /* shadcn/ui sites: use your --primary, --radius, … (v3: shadcn-v3.css) */
@source "../node_modules/kibbleforms/dist"; /* let Tailwind see the component classes; adjust the relative path */
The form inherits your font and follows a .dark / [data-theme="dark"] ancestor. Tune it with props: theme (colors, radius, font, colorScheme), size (sm | md | lg), layout ({ columns: 2 }) and classNames (per-part classes), on Form or once on KibbleProvider.
No Tailwind (any bundler, or a <link>):
import 'kibbleforms/styles.css'; // compiled and scoped to [data-kibble]; nothing leaks into your page
Customization
-
CSS variables — same in both distributions. Set them globally or per form:
:root { --kb-primary: #2563eb; --kb-radius: 999px; --kb-font: var(--font-sans); } [data-kibble] { --kb-space: 1rem; }Tokens:
--kb-primary,--kb-primary-fg,--kb-bg,--kb-fg,--kb-muted,--kb-muted-fg,--kb-border,--kb-ring,--kb-error,--kb-radius,--kb-space,--kb-font. Every component also rendersdata-kb="<name>"(form,field,label,input,select,choice,option,yesno,rating,date,consent,submit,error,success,multistep,progress,nav,notice,page,help) for plain CSS targeting. -
Compose and override classes — every component takes
className, merged withtailwind-mergeso your utilities win:<Form schema={waitlist} client={kibble} className="grid grid-cols-2 gap-4"> <Field name="name" /> <Field name="email" /> <Field name="role" className="col-span-2" /> <Field name="consent" className="col-span-2" /> <Submit className="col-span-2 w-auto justify-self-end">Count me in</Submit> </Form> -
Headless — behavior and accessibility, zero markup opinions:
import { useKibbleForm, FormProvider, useField, Honeypot } from 'kibbleforms/headless'; function Email() { const { field, value, setValue, error, inputProps, errorId } = useField('email'); return ( <label> {field.label} <input {...inputProps} value={(value as string) ?? ''} onChange={(e) => setValue(e.target.value)} /> {error && <p id={errorId}>{error}</p>} </label> ); } export function Custom() { const form = useKibbleForm(waitlist, { client: kibble }); return ( <FormProvider form={form}> <form {...form.formProps}> <Honeypot /> <Email /> <button type="submit" disabled={form.status === 'submitting'}>Send</button> </form> </FormProvider> ); }useKibbleFormgives youvalues,errors,touched,pageIndex,pageCount,status,result,submitError,setValue,setTouched,validatePage,nextPage,prevPage,submit,reset,formPropsand more.useFieldreturnsinputPropswithid,name,aria-invalid,aria-describedby,aria-requiredandonBlur(we usearia-requiredinstead ofrequiredso our messages show, not the browser's).usePage()anduseFormStatus()cover navigation and status.
Components
Form (renders the whole form from the schema when given no children; MultiStep automatically when there is more than one page), KibbleProvider, Page, MultiStep, Progress, Field (picks the control by type), Input, Textarea, Select, Choice (radio or checkbox group), YesNo, Rating, DatePicker, Consent, Submit, FieldError, Success, Label, Help, Button.
Behavior you get for free: validation from the schema on submit (format errors on blur, "required" waits for submit), focus moves to the first invalid control or to the new page's heading, Enter submits a single-page form and advances a multi-step one, a honeypot field, time-to-submit, where the visitor came from (the page that led to the form, the landing page, the entry site and campaign tags; add <KibbleAttribution /> to the root layout to cover pages before the form), optional Cloudflare Turnstile (settings.turnstileSiteKey), idempotent submits with 3 retries on network/5xx errors, partial saves per step under a session id kept in sessionStorage, and the success message or redirect from settings.success (the server's echo wins).
Use <KibbleProvider client={kibble}> once (for example in a Next.js layout through a small client component) so pages can render <Form schema={…} /> without passing the client. In server-component frameworks import schemas from kibbleforms/schema and the client from kibbleforms/client; the default entry is client code.
Client SDK
import { createClient, KibbleError } from 'kibbleforms/client';
const kibble = createClient({ projectId: 'prj_…', endpoint: 'https://api.kibbleforms.com' });
const res = await kibble.submit(waitlist, { email: '[email protected]', consent: true }, { startedAt });
// res: { id, status, success?, flags? } — throws KibbleError { code, message, hint, fields, status }
await kibble.savePartial(waitlist, values, { sessionId, pageIndex: 0 }); // never throws, null on failure
The server always validates against the registered schema version; client validation is a convenience. Submissions carry an Idempotency-Key (one per logical submit, reused across retries) and the schema hash so Kibble knows which version they belong to.
CLI
Every command accepts --json (machine-readable output on stdout, nothing else). Exit codes: 0 ok, 1 error, 2 check failed. Errors always say what to do next.
| Command | What it does |
|---|---|
kibble init [--name] [--api-url] [--forms-dir kibble/forms] [--force] | Creates a sandbox project. Writes kibble.json, .kibble/secret (gitignored), kibble/client.ts, the forms directory, and a ## Kibble forms section in AGENTS.md/CLAUDE.md. Prints the claim link. |
kibble push [slug] | Registers every schema in the forms directory (loaded with jiti, TypeScript is fine). Prints version, diff and warnings. The framework plugin makes this rarely needed. |
kibble sync | The build step: with KIBBLE_SECRET_KEY (or .kibble/secret) registers every form tagged with its source (Vercel, Netlify, Cloudflare Pages, GitHub Actions, KIBBLE_ENV); without it, checks and exits 2 only when visitors would be rejected. |
kibble env vercel|netlify|print | Puts the secret key into the host's build environment (stdin for Vercel, never echoed), or prints it for pasting. |
kibble test [slug] | Sends a test submission with sample values from localhost and confirms it was stored. |
kibble status | Mode, claim state, origins, and a forms table with local-vs-remote schema drift. |
kibble check [--allow-sandbox] | Strict local check: exits 2 if the project is unclaimed or a local schema is unregistered. |
kibble claim [--open] [--wait] | Prints (and opens) the claim link; --wait polls until the project is live. |
kibble submissions [slug] [--status all|complete|partial|spam|test] [--limit 20] | Lists recent responses with their kind, date and first answers. |
kibble destinations list / add slack (prints an "Add to Slack" link for a person) or add slack <url> / add postgres "<conn>" [--table t] [--schema s] / add email <member addr> / add webhook <url> (each --form slug or --all-forms) / remove <id> / test <id> | Wire Slack, Postgres (incl. Supabase pooler), email to workspace members and signed webhooks without the dashboard. Connection strings are never echoed; webhook signing secrets print once. |
kibble domains list|add <domain>|remove <domain> | The site's production domains (allowed origins). |
kibble review <slug> [--ideal "…"] [--categories "A,B"] [--off|--on] | Show or change triage for a form (ideal response enables fit levels). |
Config: kibble.json ({ projectId, apiUrl, formsDir }), secret in .kibble/secret (KIBBLE_SECRET_KEY overrides, useful in CI), KIBBLE_API_URL sets the default API for init.
Docs
https://api.kibbleforms.com/llms.txt and /docs/*.md are written for agents and humans alike.
MIT