Skill: Form Builder Core

## Purpose

7 stars

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

$curl -o ~/.claude/skills/form-builder/SKILL.md --create-dirs "https://raw.githubusercontent.com/heldernoid/agentic-build-templates/main/projects/web-applications/form-builder/skills/form-builder/SKILL.md"

Manual Installation

  1. Download SKILL.md from GitHub
  2. Place it in .claude/skills/form-builder/SKILL.md inside your project
  3. Restart your AI agent — it will auto-discover the skill

How Skill: Form Builder Core Compares

Feature / AgentSkill: Form Builder CoreStandard Approach
Platform SupportNot specifiedLimited / Varies
Context Awareness High Baseline
Installation ComplexityUnknownN/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

7
from heldernoid/agentic-build-templates

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

7
from heldernoid/agentic-build-templates

## Purpose

Skill: Invoice Generator Core

7
from heldernoid/agentic-build-templates

## Purpose

Skill: Form Submissions

7
from heldernoid/agentic-build-templates

## Purpose

Skill: email-digest-builder

7
from heldernoid/agentic-build-templates

Use email-digest-builder to manage feeds, topics, and digest delivery through the REST API.

Skill: mcp-server-builder

7
from heldernoid/agentic-build-templates

Use mcp-server-builder to create, test, and export MCP server configurations through the REST API.

jsonl-format

7
from heldernoid/agentic-build-templates

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

7
from heldernoid/agentic-build-templates

## Overview

Skill: Status Page

7
from heldernoid/agentic-build-templates

## Overview

Skill: unit-conversion

7
from heldernoid/agentic-build-templates

## Overview

Skill: recipe-scaler

7
from heldernoid/agentic-build-templates

## Overview

reading-list

7
from heldernoid/agentic-build-templates

Operate the reading-list API to save, manage, tag, search, and export articles.