Best use case
Skill: Form Builder Core is best used when you need a repeatable AI agent workflow instead of a one-off prompt.
## Purpose
Teams using Skill: Form Builder Core should expect a more consistent output, faster repeated execution, less prompt rewriting.
When to use this skill
- You want a reusable workflow that can be run more than once with consistent structure.
When not to use this skill
- You only need a quick one-off answer and do not need a reusable workflow.
- You cannot install or maintain the underlying files, dependencies, or repository context.
Installation
Claude Code / Cursor / Codex
Manual Installation
- Download SKILL.md from GitHub
- Place it in
.claude/skills/form-builder/SKILL.mdinside your project - Restart your AI agent — it will auto-discover the skill
How Skill: Form Builder Core Compares
| Feature / Agent | Skill: Form Builder Core | Standard Approach |
|---|---|---|
| Platform Support | Not specified | Limited / Varies |
| Context Awareness | High | Baseline |
| Installation Complexity | Unknown | N/A |
Frequently Asked Questions
What does this skill do?
## Purpose
Where can I find the source code?
You can find the source code on GitHub using the link provided at the top of the page.
SKILL.md Source
# Skill: Form Builder Core
## Purpose
Implement the drag-and-drop form builder: schema management, field CRUD, multi-step page support, conditional logic evaluation, and the Zustand builder store.
---
## TypeScript Types
```typescript
// web/src/lib/fieldTypes.ts
export type FieldType =
| 'short_text' | 'long_text' | 'email' | 'number'
| 'dropdown' | 'checkbox' | 'radio'
| 'date' | 'file' | 'heading';
export interface FieldOption {
id: string; // UUID v4
label: string;
}
export type LogicOperator = 'eq' | 'neq' | 'contains' | 'gt' | 'lt';
export interface FieldCondition {
field_id: string;
operator: LogicOperator;
value: string;
}
export interface FieldDefinition {
id: string; // UUID v4
type: FieldType;
label: string;
description?: string;
required: boolean;
placeholder?: string;
options?: FieldOption[]; // dropdown | checkbox | radio
min?: number; // number
max?: number; // number
accept?: string; // file
maxSizeMb?: number; // file; default 10
level?: 1 | 2 | 3; // heading
condition?: FieldCondition;
}
export interface FormSettings {
submitButtonLabel?: string;
successMessage?: string;
successRedirectUrl?: string;
allowMultipleResponses?: boolean;
}
export interface Form {
id: number;
user_id: number;
title: string;
description: string | null;
schema: FieldDefinition[]; // parsed from JSON
pages: string[][]; // field IDs per page; parsed from JSON
settings: FormSettings; // parsed from JSON
share_token: string;
published: 0 | 1;
closed: 0 | 1;
response_count: number;
created_at: string;
updated_at: string;
}
```
---
## Builder Store (Zustand)
```typescript
// web/src/store/builderStore.ts
import { create } from 'zustand';
import { v4 as uuid } from 'uuid';
import type { FieldDefinition, Form, FormSettings } from '../lib/fieldTypes';
import { api } from '../lib/api';
interface HistoryEntry {
schema: FieldDefinition[];
pages: string[][];
}
interface BuilderStore {
form: Form | null;
selectedFieldId: string | null;
isDirty: boolean;
history: HistoryEntry[];
historyIndex: number;
// Actions
loadForm: (id: number) => Promise<void>;
setTitle: (title: string) => void;
setDescription: (description: string) => void;
addField: (type: FieldDefinition['type'], pageIndex: number, insertAtIndex?: number) => void;
updateField: (id: string, patch: Partial<FieldDefinition>) => void;
removeField: (id: string) => void;
moveField: (fromIndex: number, toIndex: number, pageIndex: number) => void;
selectField: (id: string | null) => void;
addPage: () => void;
removePage: (pageIndex: number) => void;
renamePage: (pageIndex: number, name: string) => void;
moveFieldToPage: (fieldId: string, targetPageIndex: number) => void;
updateSettings: (patch: Partial<FormSettings>) => void;
saveForm: () => Promise<void>;
undo: () => void;
redo: () => void;
}
const MAX_HISTORY = 50;
function defaultField(type: FieldDefinition['type']): FieldDefinition {
const base: FieldDefinition = { id: uuid(), type, label: labelFor(type), required: false };
if (type === 'dropdown' || type === 'checkbox' || type === 'radio') {
base.options = [
{ id: uuid(), label: 'Option 1' },
{ id: uuid(), label: 'Option 2' },
];
}
if (type === 'heading') base.level = 2;
if (type === 'file') base.maxSizeMb = 10;
return base;
}
function labelFor(type: FieldDefinition['type']): string {
const map: Record<FieldDefinition['type'], string> = {
short_text: 'Short answer', long_text: 'Long answer', email: 'Email address',
number: 'Number', dropdown: 'Dropdown', checkbox: 'Checkboxes',
radio: 'Radio buttons', date: 'Date', file: 'File upload', heading: 'Section heading',
};
return map[type];
}
export const useBuilderStore = create<BuilderStore>((set, get) => ({
form: null,
selectedFieldId: null,
isDirty: false,
history: [],
historyIndex: -1,
loadForm: async (id) => {
const form = await api.get<Form>(`/api/forms/${id}`);
set({ form, isDirty: false, history: [{ schema: form.schema, pages: form.pages }], historyIndex: 0 });
},
setTitle: (title) => set(s => ({
form: s.form ? { ...s.form, title } : s.form, isDirty: true,
})),
setDescription: (description) => set(s => ({
form: s.form ? { ...s.form, description } : s.form, isDirty: true,
})),
addField: (type, pageIndex, insertAtIndex) => set(s => {
if (!s.form) return s;
const newField = defaultField(type);
const newSchema = [...s.form.schema, newField];
const newPages = s.form.pages.map((page, i) => {
if (i !== pageIndex) return page;
const idx = insertAtIndex ?? page.length;
const copy = [...page];
copy.splice(idx, 0, newField.id);
return copy;
});
return pushHistory(s, newSchema, newPages);
}),
updateField: (id, patch) => set(s => {
if (!s.form) return s;
const newSchema = s.form.schema.map(f => f.id === id ? { ...f, ...patch } : f);
return pushHistory(s, newSchema, s.form.pages);
}),
removeField: (id) => set(s => {
if (!s.form) return s;
const newSchema = s.form.schema.filter(f => f.id !== id);
const newPages = s.form.pages.map(page => page.filter(fid => fid !== id));
return pushHistory(s, newSchema, newPages);
}),
moveField: (fromIndex, toIndex, pageIndex) => set(s => {
if (!s.form) return s;
const newPages = s.form.pages.map((page, i) => {
if (i !== pageIndex) return page;
const copy = [...page];
const [removed] = copy.splice(fromIndex, 1);
copy.splice(toIndex, 0, removed);
return copy;
});
return pushHistory(s, s.form.schema, newPages);
}),
selectField: (id) => set({ selectedFieldId: id }),
addPage: () => set(s => {
if (!s.form) return s;
const newPages = [...s.form.pages, []];
return pushHistory(s, s.form.schema, newPages);
}),
removePage: (pageIndex) => set(s => {
if (!s.form || s.form.pages[pageIndex]?.length !== 0) return s;
const newPages = s.form.pages.filter((_, i) => i !== pageIndex);
return pushHistory(s, s.form.schema, newPages);
}),
renamePage: (_pageIndex, _name) => set(s => s), // page names stored separately in form.settings
moveFieldToPage: (fieldId, targetPageIndex) => set(s => {
if (!s.form) return s;
const newPages = s.form.pages.map(page => page.filter(fid => fid !== fieldId));
newPages[targetPageIndex] = [...(newPages[targetPageIndex] ?? []), fieldId];
return pushHistory(s, s.form.schema, newPages);
}),
updateSettings: (patch) => set(s => ({
form: s.form ? { ...s.form, settings: { ...s.form.settings, ...patch } } : s.form,
isDirty: true,
})),
saveForm: async () => {
const { form } = get();
if (!form) return;
await api.put(`/api/forms/${form.id}`, {
title: form.title,
description: form.description,
schema: form.schema,
pages: form.pages,
settings: form.settings,
});
set({ isDirty: false });
},
undo: () => set(s => {
if (s.historyIndex <= 0 || !s.form) return s;
const newIndex = s.historyIndex - 1;
const entry = s.history[newIndex];
if (!entry) return s;
return { historyIndex: newIndex, form: { ...s.form, schema: entry.schema, pages: entry.pages }, isDirty: true };
}),
redo: () => set(s => {
if (s.historyIndex >= s.history.length - 1 || !s.form) return s;
const newIndex = s.historyIndex + 1;
const entry = s.history[newIndex];
if (!entry) return s;
return { historyIndex: newIndex, form: { ...s.form, schema: entry.schema, pages: entry.pages }, isDirty: true };
}),
}));
function pushHistory(
s: BuilderStore,
schema: FieldDefinition[],
pages: string[][],
): Partial<BuilderStore> {
if (!s.form) return s;
const truncated = s.history.slice(0, s.historyIndex + 1);
const newHistory = [...truncated, { schema, pages }].slice(-MAX_HISTORY);
return {
form: { ...s.form, schema, pages },
isDirty: true,
history: newHistory,
historyIndex: newHistory.length - 1,
};
}
```
---
## Conditional Logic Evaluation
```typescript
// web/src/lib/conditions.ts
import type { FieldCondition, FieldDefinition } from './fieldTypes';
export type Answers = Record<string, string | string[] | number | null>;
export function evaluateCondition(
condition: FieldCondition,
answers: Answers,
): boolean {
const raw = answers[condition.field_id];
const answer = raw !== undefined && raw !== null ? String(raw) : '';
const target = condition.value;
switch (condition.operator) {
case 'eq': return answer === target;
case 'neq': return answer !== target;
case 'contains': return answer.toLowerCase().includes(target.toLowerCase());
case 'gt': return Number(answer) > Number(target);
case 'lt': return Number(answer) < Number(target);
default: return true;
}
}
export function isFieldVisible(field: FieldDefinition, answers: Answers): boolean {
if (!field.condition) return true;
return evaluateCondition(field.condition, answers);
}
```
---
## Schema Validation (Server-side)
```typescript
// api/src/lib/validateSchema.ts
import { v4 as uuidV4 } from 'uuid';
const VALID_TYPES = new Set([
'short_text', 'long_text', 'email', 'number',
'dropdown', 'checkbox', 'radio',
'date', 'file', 'heading',
]);
const VALID_OPERATORS = new Set(['eq', 'neq', 'contains', 'gt', 'lt']);
export function validateSchema(schema: unknown): asserts schema is FieldDefinition[] {
if (!Array.isArray(schema)) throw new Error('schema must be an array');
if (schema.length > 100) throw new Error('schema cannot exceed 100 fields');
const ids = new Set<string>();
for (const field of schema) {
if (typeof field !== 'object' || field === null) throw new Error('each field must be an object');
if (typeof field.id !== 'string' || !isUuid(field.id)) throw new Error(`field.id must be UUID v4, got: ${field.id}`);
if (ids.has(field.id)) throw new Error(`duplicate field id: ${field.id}`);
ids.add(field.id);
if (!VALID_TYPES.has(field.type)) throw new Error(`unknown field type: ${field.type}`);
if (typeof field.label !== 'string' || field.label.length > 500) throw new Error('field.label must be a string <= 500 chars');
if (field.required !== undefined && typeof field.required !== 'boolean') throw new Error('field.required must be boolean');
if (field.condition) {
const c = field.condition;
if (typeof c.field_id !== 'string') throw new Error('condition.field_id must be string');
if (!VALID_OPERATORS.has(c.operator)) throw new Error(`invalid condition operator: ${c.operator}`);
if (typeof c.value !== 'string') throw new Error('condition.value must be string');
}
}
}
function isUuid(str: string): boolean {
return /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(str);
}
```
---
## Auto-Save Hook
```typescript
// web/src/hooks/useAutoSave.ts
import { useEffect, useRef } from 'react';
import { useBuilderStore } from '../store/builderStore';
export function useAutoSave(intervalMs = 3000) {
const { isDirty, saveForm } = useBuilderStore();
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(() => {
if (!isDirty) return;
if (timerRef.current) clearTimeout(timerRef.current);
timerRef.current = setTimeout(() => {
saveForm().catch(console.error);
}, intervalMs);
return () => {
if (timerRef.current) clearTimeout(timerRef.current);
};
}, [isDirty, saveForm, intervalMs]);
}
```
---
## @dnd-kit Integration Pattern
```tsx
// web/src/components/builder/FormCanvas.tsx (abbreviated)
import {
DndContext, DragEndEvent, DragOverlay,
KeyboardSensor, PointerSensor, useSensor, useSensors,
} from '@dnd-kit/core';
import { SortableContext, sortableKeyboardCoordinates, verticalListSortingStrategy } from '@dnd-kit/sortable';
export function FormCanvas({ pageIndex }: { pageIndex: number }) {
const { form, moveField, addField } = useBuilderStore();
const sensors = useSensors(
useSensor(PointerSensor),
useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }),
);
const pageFieldIds = form?.pages[pageIndex] ?? [];
function handleDragEnd(event: DragEndEvent) {
const { active, over } = event;
if (!over || active.id === over.id) return;
// Drop from palette: active.data.current.fromPalette = true
if ((active.data.current as Record<string, unknown> | undefined)?.fromPalette) {
const type = active.data.current?.fieldType as FieldDefinition['type'];
const overIndex = pageFieldIds.indexOf(String(over.id));
addField(type, pageIndex, overIndex);
return;
}
// Reorder within canvas
const fromIndex = pageFieldIds.indexOf(String(active.id));
const toIndex = pageFieldIds.indexOf(String(over.id));
if (fromIndex !== -1 && toIndex !== -1) {
moveField(fromIndex, toIndex, pageIndex);
}
}
return (
<DndContext sensors={sensors} onDragEnd={handleDragEnd}>
<SortableContext items={pageFieldIds} strategy={verticalListSortingStrategy}>
{pageFieldIds.map(id => <FieldRow key={id} fieldId={id} />)}
</SortableContext>
</DndContext>
);
}
```
---
## API Routes Summary
| Method | Path | Description |
|---|---|---|
| GET | /api/forms | List user's forms |
| POST | /api/forms | Create form |
| GET | /api/forms/:id | Get form with parsed schema |
| PUT | /api/forms/:id | Update form (validates schema) |
| DELETE | /api/forms/:id | Delete form |
| POST | /api/forms/:id/publish | Set published=1 |
| POST | /api/forms/:id/close | Toggle closed flag |
---
## pnpm Commands
```
pnpm --filter api dev # API dev server with tsx watch
pnpm --filter web dev # Vite dev server
pnpm --filter api build # TypeScript compile
pnpm --filter web build # Vite production build
pnpm --filter api test # Vitest
pnpm --filter web test # Vitest
pnpm --filter api db:migrate # run SQL migrations
```Related Skills
poll-builder
Self-hosted poll creation tool with real-time results. Use when you need to create a poll, check vote counts, close a poll, export results, or get the shareable link for a poll. Triggers include "create poll", "vote", "poll results", "survey", "collect votes", "share poll", or any task involving polling or voting.
Skill: Pastebin Core
## Purpose
Skill: Invoice Generator Core
## Purpose
Skill: Form Submissions
## Purpose
Skill: email-digest-builder
Use email-digest-builder to manage feeds, topics, and digest delivery through the REST API.
Skill: mcp-server-builder
Use mcp-server-builder to create, test, and export MCP server configurations through the REST API.
jsonl-format
JSONL format guide for LLM fine-tuning. Covers OpenAI, Anthropic, and Llama formats, format validation rules, conversion between formats, and quality checklist.
Skill: Uptime Monitoring
## Overview
Skill: Status Page
## Overview
Skill: unit-conversion
## Overview
Skill: recipe-scaler
## Overview
reading-list
Operate the reading-list API to save, manage, tag, search, and export articles.