Project 5: Form-Based Data Entry App - Deep Dive

Project 5: Form-Based Data Entry App - Deep Dive

Build a CRM-style app for creating and editing contacts/leads with multi-step forms, validation, and confirmation flowsโ€”demonstrating write operations in ChatGPT.


Table of Contents

  1. Project Overview
  2. Theoretical Foundation
  3. Solution Architecture
  4. Step-by-Step Implementation
  5. Code Solutions
  6. Testing & Validation
  7. Common Pitfalls & Solutions
  8. Extension Ideas
  9. Performance Optimization
  10. Security Considerations
  11. Real-World Applications
  12. Learning Outcomes
  13. Resources & References

Project Overview

What Youโ€™ll Build

A fully functional CRM (Customer Relationship Management) data entry application that runs within ChatGPT, featuring:

  • Multi-step form wizards for creating and editing contacts/leads
  • Client-side and server-side validation using React Hook Form and Zod
  • Pre-filled forms from natural language input (ChatGPT extracts data)
  • Destructive action handling with proper confirmation flows
  • Accessible forms with ARIA labels and error messaging
  • Real-time validation feedback with clear error states

Why This Project Matters

Forms are the backbone of data entry in business applications. This project teaches you:

  1. Write Operations in ChatGPT Apps: Most apps are read-only; this teaches data creation and modification
  2. Validation Patterns: Client-side vs server-side validation strategies
  3. User Confirmation Flows: How to safely handle destructive actions
  4. Form Accessibility: Building inclusive forms that work for all users
  5. Integration with Natural Language: Pre-filling forms from conversational input

Project Metadata

  • Difficulty: Intermediate (Level 2)
  • Time Estimate: 1-2 weeks
  • Prerequisites: Projects 1-3, basic form handling experience
  • Main Language: TypeScript/React
  • Alternative Languages: Vue, Svelte
  • Key Tools: React Hook Form, Zod, @openai/apps-sdk-ui

Learning Objectives

By completing this project, you will:

  • Understand form validation patterns in modern web applications
  • Implement React Hook Form with Zod schema validation
  • Handle destructive actions with destructiveHint annotations
  • Build multi-step form flows with state management
  • Create accessible forms with proper ARIA attributes
  • Pre-fill forms from toolInput data
  • Distinguish between read-only and write operations
  • Handle form errors gracefully on both client and server

Theoretical Foundation

1. Form Validation Patterns

Client-Side vs Server-Side Validation

Client-Side Validation:

  • Runs in the browser before data is sent to the server
  • Provides immediate feedback to users
  • Improves UX by catching errors early
  • Cannot be trusted for security (can be bypassed)

Server-Side Validation:

  • Runs on the server after data is received
  • Acts as the security boundary
  • Catches errors client-side validation missed
  • Required for data integrity

Best Practice: Always use both layers of validation.

โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
โ”‚                   VALIDATION LAYERS                     โ”‚
โ”œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ค
โ”‚                                                         โ”‚
โ”‚  User Input                                             โ”‚
โ”‚      โ†“                                                  โ”‚
โ”‚  โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”                  โ”‚
โ”‚  โ”‚   Client-Side Validation          โ”‚                  โ”‚
โ”‚  โ”‚   โ€ข Immediate feedback            โ”‚                  โ”‚
โ”‚  โ”‚   โ€ข Type checking                 โ”‚                  โ”‚
โ”‚  โ”‚   โ€ข Format validation             โ”‚                  โ”‚
โ”‚  โ”‚   โ€ข Required fields               โ”‚                  โ”‚
โ”‚  โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜                  โ”‚
โ”‚      โ†“                                                  โ”‚
โ”‚  Network Request                                        โ”‚
โ”‚      โ†“                                                  โ”‚
โ”‚  โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”                  โ”‚
โ”‚  โ”‚   Server-Side Validation          โ”‚                  โ”‚
โ”‚  โ”‚   โ€ข Security boundary             โ”‚                  โ”‚
โ”‚  โ”‚   โ€ข Business rules                โ”‚                  โ”‚
โ”‚  โ”‚   โ€ข Database constraints          โ”‚                  โ”‚
โ”‚  โ”‚   โ€ข Authorization checks          โ”‚                  โ”‚
โ”‚  โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜                  โ”‚
โ”‚      โ†“                                                  โ”‚
โ”‚  Database Write                                         โ”‚
โ”‚                                                         โ”‚
โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜

Validation Strategies

1. Inline Validation

// Validate as user types
<Input
  {...register('email')}
  onChange={(e) => {
    // Immediate validation
    const isValid = validateEmail(e.target.value);
    setEmailError(isValid ? null : 'Invalid email');
  }}
/>

2. Blur Validation

// Validate when user leaves field
<Input
  {...register('email')}
  onBlur={() => trigger('email')}
/>

3. Submit Validation

// Validate entire form on submit
const onSubmit = async (data) => {
  // All validations run here
  const result = schema.safeParse(data);
  if (!result.success) {
    // Show errors
  }
};

2. React Hook Form

React Hook Form is a performant, flexible library for form handling in React.

Core Concepts

1. Registration

const { register } = useForm();

// Register an input
<input {...register('firstName')} />

// With validation rules
<input {...register('email', {
  required: 'Email is required',
  pattern: {
    value: /^[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}$/i,
    message: 'Invalid email address'
  }
})} />

2. Form State

const { formState: { errors, isSubmitting, isDirty } } = useForm();

// errors: Validation errors for each field
// isSubmitting: True while form is being submitted
// isDirty: True if any field has been modified

3. Watch and Control

const { watch, control } = useForm();

// Watch specific field
const emailValue = watch('email');

// Watch all fields
const formData = watch();

// Controlled components
<Controller
  name="country"
  control={control}
  render={({ field }) => <Select {...field} />}
/>

Performance Benefits

React Hook Form uses uncontrolled components by default:

  • Minimizes re-renders
  • No state updates on every keystroke
  • Faster form interactions

3. Schema Validation with Zod

Zod is a TypeScript-first schema validation library.

Basic Schema Definition

import { z } from 'zod';

const leadSchema = z.object({
  firstName: z.string().min(1, 'First name is required'),
  lastName: z.string().min(1, 'Last name is required'),
  email: z.string().email('Invalid email address').optional(),
  phone: z.string().regex(/^\+?[1-9]\d{1,14}$/, 'Invalid phone number').optional(),
  company: z.string().optional(),
  jobTitle: z.string().optional(),
  leadSource: z.enum(['website', 'referral', 'cold-call', 'event']),
  notes: z.string().max(1000, 'Notes must be less than 1000 characters').optional(),
});

// TypeScript type inference
type LeadFormData = z.infer<typeof leadSchema>;

Advanced Validation Patterns

1. Conditional Validation

const schema = z.object({
  contactMethod: z.enum(['email', 'phone']),
  email: z.string().email().optional(),
  phone: z.string().optional(),
}).refine(
  (data) => {
    if (data.contactMethod === 'email') {
      return !!data.email;
    }
    if (data.contactMethod === 'phone') {
      return !!data.phone;
    }
    return true;
  },
  {
    message: 'Email or phone is required based on contact method',
    path: ['contactMethod'],
  }
);

2. Custom Validators

const businessEmailValidator = z.string().refine(
  (email) => {
    const domain = email.split('@')[1];
    const freeProviders = ['gmail.com', 'yahoo.com', 'hotmail.com'];
    return !freeProviders.includes(domain);
  },
  {
    message: 'Please use a business email address',
  }
);

3. Async Validation

const uniqueEmailValidator = z.string().refine(
  async (email) => {
    const exists = await checkEmailExists(email);
    return !exists;
  },
  {
    message: 'Email already exists in the system',
  }
);

4. Destructive Action Handling

In ChatGPT Apps, certain operations require user confirmation before execution.

destructiveHint Annotation

@mcp.tool(
    annotations={
        "readOnlyHint": False,
        "destructiveHint": True  # Marks this as destructive
    }
)
def delete_lead(lead_id: str) -> dict:
    """Use this when user wants to delete a lead. This action is irreversible."""
    # ChatGPT will prompt user for confirmation before calling this
    delete_lead_from_database(lead_id)
    return {
        "structuredContent": {
            "success": True,
            "message": "Lead deleted successfully"
        }
    }

When to Use destructiveHint

Operation destructiveHint Reason
Create record False Creates new data, reversible
Update record False Modifies existing data, but preserves it
Delete record True Permanently removes data
Archive record False Soft delete, reversible
Batch delete True Permanent bulk removal
Reset password True Security-critical action

Confirmation Flow

User: "Delete the lead for John Smith"
     โ†“
ChatGPT: "This will permanently delete the lead for John Smith.
          Are you sure you want to proceed?"
     โ†“
User: "Yes, delete it"
     โ†“
ChatGPT calls delete_lead tool
     โ†“
Confirmation message shown

5. Multi-Step Form Flows

Multi-step forms break complex data entry into manageable chunks.

State Management Patterns

Pattern 1: Single Component with Steps

function MultiStepForm() {
  const [step, setStep] = useState(1);
  const [formData, setFormData] = useState({});

  const handleNext = (stepData) => {
    setFormData({ ...formData, ...stepData });
    setStep(step + 1);
  };

  return (
    <>
      {step === 1 && <BasicInfoStep onNext={handleNext} />}
      {step === 2 && <ContactInfoStep onNext={handleNext} />}
      {step === 3 && <ReviewStep data={formData} />}
    </>
  );
}

Pattern 2: Widget State Persistence

function MultiStepForm() {
  const [state, setState] = useState(() => {
    // Load from widgetState
    return window.openai?.widgetState || { step: 1, data: {} };
  });

  useEffect(() => {
    // Persist to widgetState
    window.openai?.setWidgetState(state);
  }, [state]);

  // Form persists across ChatGPT turns
}

Progress Indicators

function ProgressBar({ currentStep, totalSteps }) {
  return (
    <div className="flex items-center gap-2">
      {Array.from({ length: totalSteps }).map((_, i) => (
        <div
          key={i}
          className={`h-2 flex-1 rounded ${
            i < currentStep ? 'bg-blue-500' : 'bg-gray-300'
          }`}
        />
      ))}
    </div>
  );
}

6. Form Accessibility

Forms must be accessible to all users, including those using assistive technologies.

ARIA Labels

<label htmlFor="email" className="sr-only">
  Email Address
</label>
<input
  id="email"
  type="email"
  aria-label="Email Address"
  aria-required="true"
  aria-invalid={!!errors.email}
  aria-describedby={errors.email ? 'email-error' : undefined}
  {...register('email')}
/>
{errors.email && (
  <span id="email-error" role="alert" className="text-red-500">
    {errors.email.message}
  </span>
)}

Keyboard Navigation

// Tab order should be logical
<form onSubmit={handleSubmit(onSubmit)}>
  <input tabIndex={1} {...register('firstName')} />
  <input tabIndex={2} {...register('lastName')} />
  <input tabIndex={3} {...register('email')} />
  <button type="submit" tabIndex={4}>Submit</button>
</form>

// Handle Enter key for submission
<input
  onKeyDown={(e) => {
    if (e.key === 'Enter') {
      handleSubmit(onSubmit)();
    }
  }}
/>

Error Message Best Practices

// โœ“ GOOD: Specific and actionable
"Email must be in format: name@domain.com"

// โœ— BAD: Vague and unhelpful
"Invalid input"

// โœ“ GOOD: Explains the requirement
"Password must be at least 8 characters with 1 uppercase letter"

// โœ— BAD: Doesn't explain what's wrong
"Password is weak"

Solution Architecture

System Overview

โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
โ”‚                    FORM-BASED APP ARCHITECTURE                โ”‚
โ”œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ค
โ”‚                                                               โ”‚
โ”‚  โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”     โ”‚
โ”‚  โ”‚              ChatGPT Conversation                   โ”‚     โ”‚
โ”‚  โ”‚  User: "Add a new lead for John Smith from Acme"    โ”‚     โ”‚
โ”‚  โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜     โ”‚
โ”‚                           โ†“                                   โ”‚
โ”‚                   ChatGPT extracts:                           โ”‚
โ”‚                   { firstName: "John",                        โ”‚
โ”‚                     lastName: "Smith",                        โ”‚
โ”‚                     company: "Acme" }                         โ”‚
โ”‚                           โ†“                                   โ”‚
โ”‚  โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”     โ”‚
โ”‚  โ”‚         MCP Tool: create_lead_form()                โ”‚     โ”‚
โ”‚  โ”‚         Returns: toolInput + uiComponent            โ”‚     โ”‚
โ”‚  โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜     โ”‚
โ”‚                           โ†“                                   โ”‚
โ”‚  โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”     โ”‚
โ”‚  โ”‚              Form Widget (iframe)                   โ”‚     โ”‚
โ”‚  โ”‚  โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”     โ”‚     โ”‚
โ”‚  โ”‚  โ”‚  window.openai.toolInput                  โ”‚     โ”‚     โ”‚
โ”‚  โ”‚  โ”‚  โ†’ Pre-fills form with extracted data     โ”‚     โ”‚     โ”‚
โ”‚  โ”‚  โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜     โ”‚     โ”‚
โ”‚  โ”‚                                                     โ”‚     โ”‚
โ”‚  โ”‚  โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”     โ”‚     โ”‚
โ”‚  โ”‚  โ”‚  React Hook Form + Zod Validation         โ”‚     โ”‚     โ”‚
โ”‚  โ”‚  โ”‚  โ€ข Client-side validation                 โ”‚     โ”‚     โ”‚
โ”‚  โ”‚  โ”‚  โ€ข Real-time error feedback               โ”‚     โ”‚     โ”‚
โ”‚  โ”‚  โ”‚  โ€ข Accessible form elements               โ”‚     โ”‚     โ”‚
โ”‚  โ”‚  โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜     โ”‚     โ”‚
โ”‚  โ”‚                                                     โ”‚     โ”‚
โ”‚  โ”‚  User completes form and clicks "Create Lead"      โ”‚     โ”‚
โ”‚  โ”‚                                                     โ”‚     โ”‚
โ”‚  โ”‚  โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”     โ”‚     โ”‚
โ”‚  โ”‚  โ”‚  window.openai.callTool()                 โ”‚     โ”‚     โ”‚
โ”‚  โ”‚  โ”‚  โ†’ Calls create_lead with form data       โ”‚     โ”‚     โ”‚
โ”‚  โ”‚  โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜     โ”‚     โ”‚
โ”‚  โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜     โ”‚
โ”‚                           โ†“                                   โ”‚
โ”‚  โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”     โ”‚
โ”‚  โ”‚         MCP Tool: create_lead(data)                 โ”‚     โ”‚
โ”‚  โ”‚         โ€ข Server-side validation (Zod)              โ”‚     โ”‚
โ”‚  โ”‚         โ€ข Database insert                           โ”‚     โ”‚
โ”‚  โ”‚         โ€ข Return success/error                      โ”‚     โ”‚
โ”‚  โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜     โ”‚
โ”‚                           โ†“                                   โ”‚
โ”‚  โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”     โ”‚
โ”‚  โ”‚              Success Message                        โ”‚     โ”‚
โ”‚  โ”‚  "Lead John Smith created successfully!"            โ”‚     โ”‚
โ”‚  โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜     โ”‚
โ”‚                                                               โ”‚
โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜

Component Structure

src/
โ”œโ”€โ”€ components/
โ”‚   โ”œโ”€โ”€ forms/
โ”‚   โ”‚   โ”œโ”€โ”€ LeadForm.tsx              # Main form component
โ”‚   โ”‚   โ”œโ”€โ”€ ContactForm.tsx           # Contact creation form
โ”‚   โ”‚   โ”œโ”€โ”€ EditLeadForm.tsx          # Edit existing lead
โ”‚   โ”‚   โ””โ”€โ”€ MultiStepForm/
โ”‚   โ”‚       โ”œโ”€โ”€ index.tsx             # Multi-step wrapper
โ”‚   โ”‚       โ”œโ”€โ”€ BasicInfoStep.tsx     # Step 1: Name, company
โ”‚   โ”‚       โ”œโ”€โ”€ ContactInfoStep.tsx   # Step 2: Email, phone
โ”‚   โ”‚       โ”œโ”€โ”€ AdditionalInfoStep.tsx # Step 3: Notes, source
โ”‚   โ”‚       โ””โ”€โ”€ ReviewStep.tsx        # Step 4: Review and submit
โ”‚   โ”œโ”€โ”€ ui/
โ”‚   โ”‚   โ”œโ”€โ”€ FormField.tsx             # Reusable form field
โ”‚   โ”‚   โ”œโ”€โ”€ ErrorMessage.tsx          # Error display
โ”‚   โ”‚   โ”œโ”€โ”€ ProgressIndicator.tsx     # Multi-step progress
โ”‚   โ”‚   โ””โ”€โ”€ ConfirmDialog.tsx         # Destructive action confirmation
โ”‚   โ””โ”€โ”€ widgets/
โ”‚       โ”œโ”€โ”€ CreateLeadWidget.tsx      # Create lead widget
โ”‚       โ”œโ”€โ”€ EditLeadWidget.tsx        # Edit lead widget
โ”‚       โ””โ”€โ”€ DeleteConfirmWidget.tsx   # Delete confirmation
โ”œโ”€โ”€ schemas/
โ”‚   โ”œโ”€โ”€ leadSchema.ts                 # Zod schema for leads
โ”‚   โ”œโ”€โ”€ contactSchema.ts              # Zod schema for contacts
โ”‚   โ””โ”€โ”€ validationRules.ts            # Shared validation rules
โ”œโ”€โ”€ hooks/
โ”‚   โ”œโ”€โ”€ useFormPersistence.ts         # Widget state persistence
โ”‚   โ”œโ”€โ”€ useMultiStepForm.ts           # Multi-step form logic
โ”‚   โ””โ”€โ”€ useToolCall.ts                # window.openai.callTool wrapper
โ”œโ”€โ”€ utils/
โ”‚   โ”œโ”€โ”€ formHelpers.ts                # Form utilities
โ”‚   โ””โ”€โ”€ validation.ts                 # Custom validators
โ””โ”€โ”€ types/
    โ”œโ”€โ”€ forms.ts                      # Form type definitions
    โ””โ”€โ”€ api.ts                        # API response types

MCP Server Structure

# server/main.py
from fastmcp import FastMCP
from pydantic import BaseModel, EmailStr, Field
from typing import Optional, Literal
import sqlite3

mcp = FastMCP("CRM Data Entry App")

# Pydantic models for validation
class Lead(BaseModel):
    first_name: str = Field(..., min_length=1, max_length=100)
    last_name: str = Field(..., min_length=1, max_length=100)
    email: Optional[EmailStr] = None
    phone: Optional[str] = Field(None, pattern=r'^\+?[1-9]\d{1,14}$')
    company: Optional[str] = Field(None, max_length=200)
    job_title: Optional[str] = Field(None, max_length=100)
    lead_source: Literal['website', 'referral', 'cold-call', 'event']
    notes: Optional[str] = Field(None, max_length=1000)

# Show form (pre-filled from ChatGPT extraction)
@mcp.tool(
    annotations={"readOnlyHint": True}
)
def create_lead_form(
    first_name: str = "",
    last_name: str = "",
    company: str = "",
    email: str = "",
    phone: str = ""
) -> dict:
    """Use this when user wants to add a new lead. Show a form pre-filled with any extracted information."""
    return {
        "structuredContent": {
            "formType": "create_lead",
            "prefill": {
                "firstName": first_name,
                "lastName": last_name,
                "company": company,
                "email": email,
                "phone": phone
            }
        },
        "uiComponent": {
            "type": "iframe",
            "url": "https://your-app.com/widgets/create-lead.html"
        }
    }

# Create lead (called from form widget)
@mcp.tool(
    annotations={
        "readOnlyHint": False,
        "destructiveHint": False  # Create is not destructive
    }
)
def create_lead(
    first_name: str,
    last_name: str,
    email: Optional[str] = None,
    phone: Optional[str] = None,
    company: Optional[str] = None,
    job_title: Optional[str] = None,
    lead_source: str = "website",
    notes: Optional[str] = None
) -> dict:
    """Use this when user confirms they want to create a lead with the provided information."""
    # Server-side validation
    try:
        lead = Lead(
            first_name=first_name,
            last_name=last_name,
            email=email,
            phone=phone,
            company=company,
            job_title=job_title,
            lead_source=lead_source,
            notes=notes
        )
    except Exception as e:
        return {
            "structuredContent": {
                "success": False,
                "error": str(e)
            }
        }

    # Insert into database
    lead_id = insert_lead_to_database(lead)

    return {
        "structuredContent": {
            "success": True,
            "leadId": lead_id,
            "lead": lead.dict(),
            "message": f"Lead {first_name} {last_name} created successfully"
        }
    }

# Edit lead form
@mcp.tool(annotations={"readOnlyHint": True})
def edit_lead_form(lead_id: str) -> dict:
    """Use this when user wants to edit an existing lead."""
    lead = get_lead_from_database(lead_id)
    return {
        "structuredContent": {
            "formType": "edit_lead",
            "leadId": lead_id,
            "currentData": lead
        },
        "uiComponent": {
            "type": "iframe",
            "url": f"https://your-app.com/widgets/edit-lead.html?id={lead_id}"
        }
    }

# Update lead
@mcp.tool(
    annotations={
        "readOnlyHint": False,
        "destructiveHint": False  # Update preserves data
    }
)
def update_lead(
    lead_id: str,
    first_name: str,
    last_name: str,
    email: Optional[str] = None,
    phone: Optional[str] = None,
    company: Optional[str] = None,
    job_title: Optional[str] = None,
    lead_source: str = "website",
    notes: Optional[str] = None
) -> dict:
    """Use this when user confirms they want to update a lead."""
    # Validation + update logic
    pass

# Delete lead (requires confirmation)
@mcp.tool(
    annotations={
        "readOnlyHint": False,
        "destructiveHint": True  # Delete IS destructive
    }
)
def delete_lead(lead_id: str) -> dict:
    """Use this when user wants to delete a lead. This action is irreversible."""
    delete_lead_from_database(lead_id)
    return {
        "structuredContent": {
            "success": True,
            "message": "Lead deleted successfully"
        }
    }

Data Flow Diagram

โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
โ”‚                    CREATE LEAD FLOW                            โ”‚
โ”œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ค
โ”‚                                                                โ”‚
โ”‚  1. User Input (Natural Language)                              โ”‚
โ”‚     "Add John Smith from Acme Corp as a lead"                  โ”‚
โ”‚                           โ†“                                    โ”‚
โ”‚  2. ChatGPT Extraction                                         โ”‚
โ”‚     Extracts: firstName="John", lastName="Smith",              โ”‚
โ”‚               company="Acme Corp"                              โ”‚
โ”‚                           โ†“                                    โ”‚
โ”‚  3. MCP Tool Call: create_lead_form()                          โ”‚
โ”‚     Parameters: firstName, lastName, company                   โ”‚
โ”‚     Returns: toolInput + widget URL                            โ”‚
โ”‚                           โ†“                                    โ”‚
โ”‚  4. Widget Renders                                             โ”‚
โ”‚     โ€ข Reads window.openai.toolInput                            โ”‚
โ”‚     โ€ข Pre-fills form fields                                    โ”‚
โ”‚     โ€ข User completes missing fields                            โ”‚
โ”‚                           โ†“                                    โ”‚
โ”‚  5. Client-Side Validation (Zod)                               โ”‚
โ”‚     โ€ข Check required fields                                    โ”‚
โ”‚     โ€ข Validate email format                                    โ”‚
โ”‚     โ€ข Validate phone format                                    โ”‚
โ”‚     โ€ข Show inline errors                                       โ”‚
โ”‚                           โ†“                                    โ”‚
โ”‚  6. Form Submission                                            โ”‚
โ”‚     window.openai.callTool('create_lead', formData)            โ”‚
โ”‚                           โ†“                                    โ”‚
โ”‚  7. Server-Side Validation (Pydantic)                          โ”‚
โ”‚     โ€ข Re-validate all fields                                   โ”‚
โ”‚     โ€ข Check business rules                                     โ”‚
โ”‚     โ€ข Return errors if invalid                                 โ”‚
โ”‚                           โ†“                                    โ”‚
โ”‚  8. Database Insert                                            โ”‚
โ”‚     INSERT INTO leads (...) VALUES (...)                       โ”‚
โ”‚                           โ†“                                    โ”‚
โ”‚  9. Success Response                                           โ”‚
โ”‚     { success: true, leadId: "123", message: "..." }           โ”‚
โ”‚                           โ†“                                    โ”‚
โ”‚  10. ChatGPT Confirmation                                      โ”‚
โ”‚      "Lead John Smith created successfully!"                   โ”‚
โ”‚                                                                โ”‚
โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜

Step-by-Step Implementation

Step 1: Project Setup

Initialize React Project

# Create Vite project with React and TypeScript
npm create vite@latest crm-form-app -- --template react-ts
cd crm-form-app

# Install dependencies
npm install

# Install form libraries
npm install react-hook-form @hookform/resolvers zod

# Install OpenAI Apps SDK UI
npm install @openai/apps-sdk-ui

# Install utility libraries
npm install clsx tailwind-merge

# Install dev dependencies
npm install -D @types/react @types/react-dom vite-plugin-singlefile

Configure Vite for Widget Bundle

// vite.config.ts
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import { viteSingleFile } from 'vite-plugin-singlefile';

export default defineConfig({
  plugins: [
    react(),
    viteSingleFile(), // Bundle into single HTML file
  ],
  build: {
    outDir: 'dist',
    rollupOptions: {
      output: {
        inlineDynamicImports: true,
      },
    },
  },
  server: {
    port: 3000,
  },
});

Project Structure

mkdir -p src/{components/{forms,ui,widgets},schemas,hooks,utils,types}

Step 2: Define Validation Schemas

// src/schemas/leadSchema.ts
import { z } from 'zod';

// Phone number regex (E.164 format)
const phoneRegex = /^\+?[1-9]\d{1,14}$/;

export const leadSchema = z.object({
  firstName: z
    .string()
    .min(1, 'First name is required')
    .max(100, 'First name must be less than 100 characters'),

  lastName: z
    .string()
    .min(1, 'Last name is required')
    .max(100, 'Last name must be less than 100 characters'),

  email: z
    .string()
    .email('Invalid email address')
    .optional()
    .or(z.literal('')),

  phone: z
    .string()
    .regex(phoneRegex, 'Invalid phone number format (use: +1234567890)')
    .optional()
    .or(z.literal('')),

  company: z
    .string()
    .max(200, 'Company name must be less than 200 characters')
    .optional(),

  jobTitle: z
    .string()
    .max(100, 'Job title must be less than 100 characters')
    .optional(),

  leadSource: z.enum(['website', 'referral', 'cold-call', 'event'], {
    errorMap: () => ({ message: 'Please select a valid lead source' }),
  }),

  notes: z
    .string()
    .max(1000, 'Notes must be less than 1000 characters')
    .optional(),
});

// At least one contact method required
export const leadSchemaWithContactMethod = leadSchema.refine(
  (data) => data.email || data.phone,
  {
    message: 'At least one contact method (email or phone) is required',
    path: ['email'], // Show error on email field
  }
);

// TypeScript type
export type LeadFormData = z.infer<typeof leadSchema>;
// src/schemas/validationRules.ts
export const validationRules = {
  businessEmail: (email: string): boolean => {
    const domain = email.split('@')[1];
    const freeProviders = [
      'gmail.com',
      'yahoo.com',
      'hotmail.com',
      'outlook.com',
    ];
    return !freeProviders.includes(domain);
  },

  strongPassword: (password: string): boolean => {
    // At least 8 chars, 1 uppercase, 1 lowercase, 1 number
    return /^(?=.*[a-z])(?=.*[A-Z])(?=.*\d).{8,}$/.test(password);
  },

  noSpecialChars: (value: string): boolean => {
    return /^[a-zA-Z0-9\s]*$/.test(value);
  },
};

Step 3: Create Reusable Form Components

// src/components/ui/FormField.tsx
import React from 'react';
import { FieldError } from 'react-hook-form';
import clsx from 'clsx';

interface FormFieldProps {
  label: string;
  name: string;
  error?: FieldError;
  required?: boolean;
  children: React.ReactNode;
  helpText?: string;
}

export function FormField({
  label,
  name,
  error,
  required = false,
  children,
  helpText,
}: FormFieldProps) {
  const fieldId = `field-${name}`;
  const errorId = `${fieldId}-error`;
  const helpId = `${fieldId}-help`;

  return (
    <div className="mb-4">
      <label
        htmlFor={fieldId}
        className="block text-sm font-medium text-gray-700 mb-1"
      >
        {label}
        {required && <span className="text-red-500 ml-1">*</span>}
      </label>

      <div className="relative">
        {React.cloneElement(children as React.ReactElement, {
          id: fieldId,
          'aria-invalid': !!error,
          'aria-describedby': clsx({
            [errorId]: !!error,
            [helpId]: !!helpText,
          }),
          'aria-required': required,
        })}
      </div>

      {helpText && !error && (
        <p id={helpId} className="mt-1 text-sm text-gray-500">
          {helpText}
        </p>
      )}

      {error && (
        <p
          id={errorId}
          role="alert"
          className="mt-1 text-sm text-red-600"
        >
          {error.message}
        </p>
      )}
    </div>
  );
}
// src/components/ui/ErrorMessage.tsx
interface ErrorMessageProps {
  message: string;
}

export function ErrorMessage({ message }: ErrorMessageProps) {
  return (
    <div
      role="alert"
      className="bg-red-50 border border-red-200 text-red-800 px-4 py-3 rounded"
    >
      <div className="flex items-center gap-2">
        <svg
          className="w-5 h-5"
          fill="currentColor"
          viewBox="0 0 20 20"
        >
          <path
            fillRule="evenodd"
            d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z"
            clipRule="evenodd"
          />
        </svg>
        <span>{message}</span>
      </div>
    </div>
  );
}
// src/components/ui/ProgressIndicator.tsx
interface ProgressIndicatorProps {
  currentStep: number;
  totalSteps: number;
  stepLabels?: string[];
}

export function ProgressIndicator({
  currentStep,
  totalSteps,
  stepLabels,
}: ProgressIndicatorProps) {
  return (
    <div className="mb-6">
      {/* Progress bar */}
      <div className="flex items-center gap-2 mb-2">
        {Array.from({ length: totalSteps }).map((_, index) => {
          const stepNumber = index + 1;
          const isActive = stepNumber === currentStep;
          const isCompleted = stepNumber < currentStep;

          return (
            <React.Fragment key={index}>
              <div
                className={clsx(
                  'flex items-center justify-center w-8 h-8 rounded-full text-sm font-medium',
                  {
                    'bg-blue-500 text-white': isActive,
                    'bg-green-500 text-white': isCompleted,
                    'bg-gray-300 text-gray-600': !isActive && !isCompleted,
                  }
                )}
                aria-current={isActive ? 'step' : undefined}
              >
                {isCompleted ? 'โœ“' : stepNumber}
              </div>

              {index < totalSteps - 1 && (
                <div
                  className={clsx('h-1 flex-1 rounded', {
                    'bg-green-500': isCompleted,
                    'bg-gray-300': !isCompleted,
                  })}
                />
              )}
            </React.Fragment>
          );
        })}
      </div>

      {/* Step label */}
      {stepLabels && (
        <p className="text-center text-sm text-gray-600">
          Step {currentStep} of {totalSteps}: {stepLabels[currentStep - 1]}
        </p>
      )}
    </div>
  );
}

Step 4: Build the Main Form Component

// src/components/forms/LeadForm.tsx
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { leadSchemaWithContactMethod, LeadFormData } from '../../schemas/leadSchema';
import { FormField } from '../ui/FormField';
import { ErrorMessage } from '../ui/ErrorMessage';
import { Button, Input, Textarea, Select } from '@openai/apps-sdk-ui';
import { useState } from 'react';

interface LeadFormProps {
  initialData?: Partial<LeadFormData>;
  onSubmit: (data: LeadFormData) => Promise<void>;
  onCancel?: () => void;
  submitLabel?: string;
}

export function LeadForm({
  initialData,
  onSubmit,
  onCancel,
  submitLabel = 'Create Lead',
}: LeadFormProps) {
  const [serverError, setServerError] = useState<string | null>(null);

  const {
    register,
    handleSubmit,
    formState: { errors, isSubmitting },
    watch,
  } = useForm<LeadFormData>({
    resolver: zodResolver(leadSchemaWithContactMethod),
    defaultValues: initialData || {
      leadSource: 'website',
    },
  });

  const onSubmitHandler = async (data: LeadFormData) => {
    setServerError(null);
    try {
      await onSubmit(data);
    } catch (error) {
      setServerError(
        error instanceof Error ? error.message : 'An error occurred'
      );
    }
  };

  // Watch email and phone to show contact method warning
  const email = watch('email');
  const phone = watch('phone');

  return (
    <form onSubmit={handleSubmit(onSubmitHandler)} className="space-y-4">
      <h2 className="text-xl font-bold mb-4">
        {submitLabel === 'Create Lead' ? 'Add New Lead' : 'Edit Lead'}
      </h2>

      {serverError && <ErrorMessage message={serverError} />}

      {/* Basic Information */}
      <div className="grid grid-cols-2 gap-4">
        <FormField
          label="First Name"
          name="firstName"
          required
          error={errors.firstName}
        >
          <Input
            {...register('firstName')}
            placeholder="John"
            autoComplete="given-name"
          />
        </FormField>

        <FormField
          label="Last Name"
          name="lastName"
          required
          error={errors.lastName}
        >
          <Input
            {...register('lastName')}
            placeholder="Smith"
            autoComplete="family-name"
          />
        </FormField>
      </div>

      {/* Contact Information */}
      <FormField
        label="Email"
        name="email"
        error={errors.email}
        helpText="Preferred contact method"
      >
        <Input
          {...register('email')}
          type="email"
          placeholder="john.smith@company.com"
          autoComplete="email"
        />
      </FormField>

      <FormField
        label="Phone"
        name="phone"
        error={errors.phone}
        helpText="Include country code (e.g., +1 for US)"
      >
        <Input
          {...register('phone')}
          type="tel"
          placeholder="+1234567890"
          autoComplete="tel"
        />
      </FormField>

      {!email && !phone && (
        <div className="bg-yellow-50 border border-yellow-200 text-yellow-800 px-4 py-3 rounded">
          <p className="text-sm">
            Please provide at least one contact method (email or phone)
          </p>
        </div>
      )}

      {/* Company Information */}
      <div className="grid grid-cols-2 gap-4">
        <FormField
          label="Company"
          name="company"
          error={errors.company}
        >
          <Input
            {...register('company')}
            placeholder="Acme Corp"
            autoComplete="organization"
          />
        </FormField>

        <FormField
          label="Job Title"
          name="jobTitle"
          error={errors.jobTitle}
        >
          <Input
            {...register('jobTitle')}
            placeholder="Sales Manager"
            autoComplete="organization-title"
          />
        </FormField>
      </div>

      {/* Lead Source */}
      <FormField
        label="Lead Source"
        name="leadSource"
        required
        error={errors.leadSource}
      >
        <Select {...register('leadSource')}>
          <option value="website">Website</option>
          <option value="referral">Referral</option>
          <option value="cold-call">Cold Call</option>
          <option value="event">Event</option>
        </Select>
      </FormField>

      {/* Notes */}
      <FormField
        label="Notes"
        name="notes"
        error={errors.notes}
        helpText={`${watch('notes')?.length || 0}/1000 characters`}
      >
        <Textarea
          {...register('notes')}
          rows={4}
          placeholder="Additional information about this lead..."
        />
      </FormField>

      {/* Action buttons */}
      <div className="flex gap-3 justify-end pt-4 border-t">
        {onCancel && (
          <Button
            type="button"
            variant="secondary"
            onClick={onCancel}
            disabled={isSubmitting}
          >
            Cancel
          </Button>
        )}

        <Button
          type="submit"
          disabled={isSubmitting}
          className="min-w-[120px]"
        >
          {isSubmitting ? 'Saving...' : submitLabel}
        </Button>
      </div>
    </form>
  );
}

Step 5: Create Multi-Step Form

// src/components/forms/MultiStepForm/index.tsx
import { useState } from 'react';
import { useForm, FormProvider } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { leadSchemaWithContactMethod, LeadFormData } from '../../../schemas/leadSchema';
import { ProgressIndicator } from '../../ui/ProgressIndicator';
import { BasicInfoStep } from './BasicInfoStep';
import { ContactInfoStep } from './ContactInfoStep';
import { AdditionalInfoStep } from './AdditionalInfoStep';
import { ReviewStep } from './ReviewStep';
import { useFormPersistence } from '../../../hooks/useFormPersistence';

interface MultiStepFormProps {
  initialData?: Partial<LeadFormData>;
  onSubmit: (data: LeadFormData) => Promise<void>;
  onCancel?: () => void;
}

const STEP_LABELS = [
  'Basic Information',
  'Contact Details',
  'Additional Info',
  'Review & Submit',
];

export function MultiStepForm({
  initialData,
  onSubmit,
  onCancel,
}: MultiStepFormProps) {
  const [currentStep, setCurrentStep] = useState(1);

  const methods = useForm<LeadFormData>({
    resolver: zodResolver(leadSchemaWithContactMethod),
    defaultValues: initialData || { leadSource: 'website' },
    mode: 'onChange', // Validate on change for multi-step
  });

  // Persist form state across widget reloads
  useFormPersistence(methods);

  const nextStep = async () => {
    // Validate current step before proceeding
    const isValid = await methods.trigger();
    if (isValid) {
      setCurrentStep((prev) => Math.min(prev + 1, 4));
    }
  };

  const prevStep = () => {
    setCurrentStep((prev) => Math.max(prev - 1, 1));
  };

  const handleSubmit = methods.handleSubmit(async (data) => {
    await onSubmit(data);
  });

  return (
    <FormProvider {...methods}>
      <div className="max-w-2xl mx-auto p-6">
        <h2 className="text-2xl font-bold mb-6">Add New Lead</h2>

        <ProgressIndicator
          currentStep={currentStep}
          totalSteps={4}
          stepLabels={STEP_LABELS}
        />

        <form onSubmit={handleSubmit}>
          {currentStep === 1 && <BasicInfoStep />}
          {currentStep === 2 && <ContactInfoStep />}
          {currentStep === 3 && <AdditionalInfoStep />}
          {currentStep === 4 && <ReviewStep />}

          {/* Navigation buttons */}
          <div className="flex gap-3 justify-between pt-6 border-t mt-6">
            <div>
              {onCancel && (
                <Button
                  type="button"
                  variant="secondary"
                  onClick={onCancel}
                >
                  Cancel
                </Button>
              )}
            </div>

            <div className="flex gap-3">
              {currentStep > 1 && (
                <Button
                  type="button"
                  variant="secondary"
                  onClick={prevStep}
                >
                  โ† Previous
                </Button>
              )}

              {currentStep < 4 ? (
                <Button type="button" onClick={nextStep}>
                  Next โ†’
                </Button>
              ) : (
                <Button type="submit">
                  Create Lead
                </Button>
              )}
            </div>
          </div>
        </form>
      </div>
    </FormProvider>
  );
}
// src/components/forms/MultiStepForm/BasicInfoStep.tsx
import { useFormContext } from 'react-hook-form';
import { FormField } from '../../ui/FormField';
import { Input } from '@openai/apps-sdk-ui';
import { LeadFormData } from '../../../schemas/leadSchema';

export function BasicInfoStep() {
  const {
    register,
    formState: { errors },
  } = useFormContext<LeadFormData>();

  return (
    <div className="space-y-4">
      <div className="grid grid-cols-2 gap-4">
        <FormField
          label="First Name"
          name="firstName"
          required
          error={errors.firstName}
        >
          <Input
            {...register('firstName')}
            placeholder="John"
            autoFocus
          />
        </FormField>

        <FormField
          label="Last Name"
          name="lastName"
          required
          error={errors.lastName}
        >
          <Input {...register('lastName')} placeholder="Smith" />
        </FormField>
      </div>

      <div className="grid grid-cols-2 gap-4">
        <FormField label="Company" name="company" error={errors.company}>
          <Input {...register('company')} placeholder="Acme Corp" />
        </FormField>

        <FormField
          label="Job Title"
          name="jobTitle"
          error={errors.jobTitle}
        >
          <Input {...register('jobTitle')} placeholder="Sales Manager" />
        </FormField>
      </div>
    </div>
  );
}
// src/components/forms/MultiStepForm/ContactInfoStep.tsx
import { useFormContext } from 'react-hook-form';
import { FormField } from '../../ui/FormField';
import { Input } from '@openai/apps-sdk-ui';
import { LeadFormData } from '../../../schemas/leadSchema';

export function ContactInfoStep() {
  const {
    register,
    formState: { errors },
    watch,
  } = useFormContext<LeadFormData>();

  const email = watch('email');
  const phone = watch('phone');

  return (
    <div className="space-y-4">
      <FormField
        label="Email Address"
        name="email"
        error={errors.email}
        helpText="Primary contact method"
      >
        <Input
          {...register('email')}
          type="email"
          placeholder="john.smith@company.com"
          autoFocus
        />
      </FormField>

      <FormField
        label="Phone Number"
        name="phone"
        error={errors.phone}
        helpText="Include country code (e.g., +1 for US)"
      >
        <Input
          {...register('phone')}
          type="tel"
          placeholder="+1234567890"
        />
      </FormField>

      {!email && !phone && (
        <div className="bg-yellow-50 border border-yellow-200 text-yellow-800 px-4 py-3 rounded">
          <p className="text-sm font-medium">Contact Method Required</p>
          <p className="text-sm mt-1">
            Please provide at least one contact method (email or phone) to
            proceed.
          </p>
        </div>
      )}
    </div>
  );
}
// src/components/forms/MultiStepForm/AdditionalInfoStep.tsx
import { useFormContext } from 'react-hook-form';
import { FormField } from '../../ui/FormField';
import { Select, Textarea } from '@openai/apps-sdk-ui';
import { LeadFormData } from '../../../schemas/leadSchema';

export function AdditionalInfoStep() {
  const {
    register,
    formState: { errors },
    watch,
  } = useFormContext<LeadFormData>();

  const notesLength = watch('notes')?.length || 0;

  return (
    <div className="space-y-4">
      <FormField
        label="Lead Source"
        name="leadSource"
        required
        error={errors.leadSource}
        helpText="Where did this lead come from?"
      >
        <Select {...register('leadSource')}>
          <option value="website">Website</option>
          <option value="referral">Referral</option>
          <option value="cold-call">Cold Call</option>
          <option value="event">Event</option>
        </Select>
      </FormField>

      <FormField
        label="Notes"
        name="notes"
        error={errors.notes}
        helpText={`${notesLength}/1000 characters`}
      >
        <Textarea
          {...register('notes')}
          rows={6}
          placeholder="Additional information about this lead..."
        />
      </FormField>
    </div>
  );
}
// src/components/forms/MultiStepForm/ReviewStep.tsx
import { useFormContext } from 'react-hook-form';
import { LeadFormData } from '../../../schemas/leadSchema';

export function ReviewStep() {
  const { watch } = useFormContext<LeadFormData>();
  const data = watch();

  return (
    <div className="space-y-6">
      <div className="bg-blue-50 border border-blue-200 text-blue-800 px-4 py-3 rounded">
        <p className="text-sm font-medium">Review Your Information</p>
        <p className="text-sm mt-1">
          Please review the details below before submitting.
        </p>
      </div>

      {/* Basic Information */}
      <div>
        <h3 className="text-sm font-medium text-gray-500 mb-2">
          Basic Information
        </h3>
        <dl className="space-y-1">
          <div className="flex">
            <dt className="w-32 text-sm font-medium text-gray-700">Name:</dt>
            <dd className="text-sm text-gray-900">
              {data.firstName} {data.lastName}
            </dd>
          </div>
          {data.company && (
            <div className="flex">
              <dt className="w-32 text-sm font-medium text-gray-700">
                Company:
              </dt>
              <dd className="text-sm text-gray-900">{data.company}</dd>
            </div>
          )}
          {data.jobTitle && (
            <div className="flex">
              <dt className="w-32 text-sm font-medium text-gray-700">
                Job Title:
              </dt>
              <dd className="text-sm text-gray-900">{data.jobTitle}</dd>
            </div>
          )}
        </dl>
      </div>

      {/* Contact Information */}
      <div>
        <h3 className="text-sm font-medium text-gray-500 mb-2">
          Contact Information
        </h3>
        <dl className="space-y-1">
          {data.email && (
            <div className="flex">
              <dt className="w-32 text-sm font-medium text-gray-700">
                Email:
              </dt>
              <dd className="text-sm text-gray-900">{data.email}</dd>
            </div>
          )}
          {data.phone && (
            <div className="flex">
              <dt className="w-32 text-sm font-medium text-gray-700">
                Phone:
              </dt>
              <dd className="text-sm text-gray-900">{data.phone}</dd>
            </div>
          )}
        </dl>
      </div>

      {/* Additional Information */}
      <div>
        <h3 className="text-sm font-medium text-gray-500 mb-2">
          Additional Information
        </h3>
        <dl className="space-y-1">
          <div className="flex">
            <dt className="w-32 text-sm font-medium text-gray-700">
              Lead Source:
            </dt>
            <dd className="text-sm text-gray-900 capitalize">
              {data.leadSource}
            </dd>
          </div>
          {data.notes && (
            <div className="flex">
              <dt className="w-32 text-sm font-medium text-gray-700">
                Notes:
              </dt>
              <dd className="text-sm text-gray-900">{data.notes}</dd>
            </div>
          )}
        </dl>
      </div>
    </div>
  );
}

Step 6: Create Widget Components

// src/components/widgets/CreateLeadWidget.tsx
import { useEffect, useState } from 'react';
import { LeadForm } from '../forms/LeadForm';
import { LeadFormData } from '../../schemas/leadSchema';

declare global {
  interface Window {
    openai?: {
      toolInput?: any;
      toolOutput?: any;
      callTool: (name: string, params: any) => void;
      sendFollowUpMessage: (message: string) => void;
    };
  }
}

export function CreateLeadWidget() {
  const [prefillData, setPrefillData] = useState<Partial<LeadFormData>>({});

  useEffect(() => {
    // Read pre-filled data from ChatGPT extraction
    if (window.openai?.toolOutput?.prefill) {
      setPrefillData(window.openai.toolOutput.prefill);
    }
  }, []);

  const handleSubmit = async (data: LeadFormData) => {
    // Call the create_lead tool
    window.openai?.callTool('create_lead', {
      first_name: data.firstName,
      last_name: data.lastName,
      email: data.email || undefined,
      phone: data.phone || undefined,
      company: data.company || undefined,
      job_title: data.jobTitle || undefined,
      lead_source: data.leadSource,
      notes: data.notes || undefined,
    });
  };

  const handleCancel = () => {
    window.openai?.sendFollowUpMessage('Cancel lead creation');
  };

  return (
    <div className="min-h-screen bg-gray-50 p-6">
      <div className="max-w-2xl mx-auto bg-white rounded-lg shadow-sm p-6">
        <LeadForm
          initialData={prefillData}
          onSubmit={handleSubmit}
          onCancel={handleCancel}
          submitLabel="Create Lead"
        />
      </div>
    </div>
  );
}

Step 7: Create Custom Hooks

// src/hooks/useFormPersistence.ts
import { useEffect } from 'react';
import { UseFormReturn } from 'react-hook-form';

export function useFormPersistence<T>(methods: UseFormReturn<T>) {
  const { watch, reset } = methods;

  useEffect(() => {
    // Load persisted state on mount
    const savedState = window.openai?.widgetState?.formData;
    if (savedState) {
      reset(savedState);
    }
  }, [reset]);

  useEffect(() => {
    // Subscribe to form changes
    const subscription = watch((data) => {
      // Persist to widget state
      if (window.openai?.setWidgetState) {
        window.openai.setWidgetState({
          formData: data,
        });
      }
    });

    return () => subscription.unsubscribe();
  }, [watch]);
}
// src/hooks/useToolCall.ts
import { useState, useCallback } from 'react';

export function useToolCall() {
  const [isLoading, setIsLoading] = useState(false);
  const [error, setError] = useState<string | null>(null);

  const callTool = useCallback(async (name: string, params: any) => {
    setIsLoading(true);
    setError(null);

    try {
      if (!window.openai?.callTool) {
        throw new Error('OpenAI API not available');
      }

      window.openai.callTool(name, params);
    } catch (err) {
      const message = err instanceof Error ? err.message : 'Unknown error';
      setError(message);
      throw err;
    } finally {
      setIsLoading(false);
    }
  }, []);

  return { callTool, isLoading, error };
}

Step 8: Build MCP Server

# server/main.py
from fastmcp import FastMCP
from pydantic import BaseModel, EmailStr, Field, validator
from typing import Optional, Literal
import sqlite3
from datetime import datetime

mcp = FastMCP("CRM Data Entry App")

# Initialize database
def init_database():
    conn = sqlite3.connect('crm.db')
    cursor = conn.cursor()
    cursor.execute('''
        CREATE TABLE IF NOT EXISTS leads (
            id INTEGER PRIMARY KEY AUTOINCREMENT,
            first_name TEXT NOT NULL,
            last_name TEXT NOT NULL,
            email TEXT,
            phone TEXT,
            company TEXT,
            job_title TEXT,
            lead_source TEXT NOT NULL,
            notes TEXT,
            created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
            updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
        )
    ''')
    conn.commit()
    conn.close()

init_database()

# Pydantic models for validation
class Lead(BaseModel):
    first_name: str = Field(..., min_length=1, max_length=100)
    last_name: str = Field(..., min_length=1, max_length=100)
    email: Optional[EmailStr] = None
    phone: Optional[str] = Field(None, pattern=r'^\+?[1-9]\d{1,14}$')
    company: Optional[str] = Field(None, max_length=200)
    job_title: Optional[str] = Field(None, max_length=100)
    lead_source: Literal['website', 'referral', 'cold-call', 'event']
    notes: Optional[str] = Field(None, max_length=1000)

    @validator('email', 'phone')
    def at_least_one_contact(cls, v, values):
        if 'email' in values and not values.get('email') and not v:
            raise ValueError('At least one contact method (email or phone) is required')
        return v

# Show create form
@mcp.tool(annotations={"readOnlyHint": True})
def create_lead_form(
    first_name: str = "",
    last_name: str = "",
    company: str = "",
    email: str = "",
    phone: str = ""
) -> dict:
    """Use this when user wants to add a new lead. Show a form pre-filled with any extracted information."""
    return {
        "structuredContent": {
            "formType": "create_lead",
            "prefill": {
                "firstName": first_name,
                "lastName": last_name,
                "company": company,
                "email": email,
                "phone": phone,
                "leadSource": "website"
            }
        },
        "uiComponent": {
            "type": "iframe",
            "url": "https://your-app.com/widgets/create-lead.html"
        }
    }

# Create lead
@mcp.tool(
    annotations={
        "readOnlyHint": False,
        "destructiveHint": False
    }
)
def create_lead(
    first_name: str,
    last_name: str,
    lead_source: str = "website",
    email: Optional[str] = None,
    phone: Optional[str] = None,
    company: Optional[str] = None,
    job_title: Optional[str] = None,
    notes: Optional[str] = None
) -> dict:
    """Use this when user confirms they want to create a lead with the provided information."""

    # Server-side validation with Pydantic
    try:
        lead = Lead(
            first_name=first_name,
            last_name=last_name,
            email=email,
            phone=phone,
            company=company,
            job_title=job_title,
            lead_source=lead_source,
            notes=notes
        )
    except Exception as e:
        return {
            "structuredContent": {
                "success": False,
                "error": str(e),
                "message": f"Validation error: {str(e)}"
            }
        }

    # Insert into database
    try:
        conn = sqlite3.connect('crm.db')
        cursor = conn.cursor()
        cursor.execute('''
            INSERT INTO leads (first_name, last_name, email, phone, company, job_title, lead_source, notes)
            VALUES (?, ?, ?, ?, ?, ?, ?, ?)
        ''', (
            lead.first_name,
            lead.last_name,
            lead.email,
            lead.phone,
            lead.company,
            lead.job_title,
            lead.lead_source,
            lead.notes
        ))
        conn.commit()
        lead_id = cursor.lastrowid
        conn.close()

        return {
            "structuredContent": {
                "success": True,
                "leadId": lead_id,
                "lead": lead.dict(),
                "message": f"Lead {first_name} {last_name} created successfully with ID {lead_id}"
            }
        }
    except Exception as e:
        return {
            "structuredContent": {
                "success": False,
                "error": str(e),
                "message": f"Database error: {str(e)}"
            }
        }

# Get lead
@mcp.tool(annotations={"readOnlyHint": True})
def get_lead(lead_id: int) -> dict:
    """Use this when user wants to view details of a specific lead."""
    conn = sqlite3.connect('crm.db')
    cursor = conn.cursor()
    cursor.execute('SELECT * FROM leads WHERE id = ?', (lead_id,))
    row = cursor.fetchone()
    conn.close()

    if not row:
        return {
            "structuredContent": {
                "success": False,
                "error": "Lead not found"
            }
        }

    lead = {
        "id": row[0],
        "firstName": row[1],
        "lastName": row[2],
        "email": row[3],
        "phone": row[4],
        "company": row[5],
        "jobTitle": row[6],
        "leadSource": row[7],
        "notes": row[8],
        "createdAt": row[9],
        "updatedAt": row[10]
    }

    return {
        "structuredContent": {
            "success": True,
            "lead": lead
        }
    }

# Edit lead form
@mcp.tool(annotations={"readOnlyHint": True})
def edit_lead_form(lead_id: int) -> dict:
    """Use this when user wants to edit an existing lead."""
    # Get current lead data
    lead_data = get_lead(lead_id)

    if not lead_data["structuredContent"]["success"]:
        return lead_data

    return {
        "structuredContent": {
            "formType": "edit_lead",
            "leadId": lead_id,
            "prefill": lead_data["structuredContent"]["lead"]
        },
        "uiComponent": {
            "type": "iframe",
            "url": f"https://your-app.com/widgets/edit-lead.html?id={lead_id}"
        }
    }

# Update lead
@mcp.tool(
    annotations={
        "readOnlyHint": False,
        "destructiveHint": False
    }
)
def update_lead(
    lead_id: int,
    first_name: str,
    last_name: str,
    lead_source: str,
    email: Optional[str] = None,
    phone: Optional[str] = None,
    company: Optional[str] = None,
    job_title: Optional[str] = None,
    notes: Optional[str] = None
) -> dict:
    """Use this when user confirms they want to update a lead."""

    # Validation
    try:
        lead = Lead(
            first_name=first_name,
            last_name=last_name,
            email=email,
            phone=phone,
            company=company,
            job_title=job_title,
            lead_source=lead_source,
            notes=notes
        )
    except Exception as e:
        return {
            "structuredContent": {
                "success": False,
                "error": str(e)
            }
        }

    # Update database
    try:
        conn = sqlite3.connect('crm.db')
        cursor = conn.cursor()
        cursor.execute('''
            UPDATE leads
            SET first_name=?, last_name=?, email=?, phone=?, company=?,
                job_title=?, lead_source=?, notes=?, updated_at=CURRENT_TIMESTAMP
            WHERE id=?
        ''', (
            lead.first_name,
            lead.last_name,
            lead.email,
            lead.phone,
            lead.company,
            lead.job_title,
            lead.lead_source,
            lead.notes,
            lead_id
        ))
        conn.commit()
        conn.close()

        return {
            "structuredContent": {
                "success": True,
                "leadId": lead_id,
                "lead": lead.dict(),
                "message": f"Lead {first_name} {last_name} updated successfully"
            }
        }
    except Exception as e:
        return {
            "structuredContent": {
                "success": False,
                "error": str(e)
            }
        }

# Delete lead (destructive)
@mcp.tool(
    annotations={
        "readOnlyHint": False,
        "destructiveHint": True  # Requires user confirmation
    }
)
def delete_lead(lead_id: int) -> dict:
    """Use this when user wants to delete a lead. This action is irreversible."""

    # Check if lead exists
    lead_data = get_lead(lead_id)
    if not lead_data["structuredContent"]["success"]:
        return lead_data

    # Delete from database
    try:
        conn = sqlite3.connect('crm.db')
        cursor = conn.cursor()
        cursor.execute('DELETE FROM leads WHERE id = ?', (lead_id,))
        conn.commit()
        conn.close()

        lead_name = f"{lead_data['structuredContent']['lead']['firstName']} {lead_data['structuredContent']['lead']['lastName']}"

        return {
            "structuredContent": {
                "success": True,
                "message": f"Lead {lead_name} (ID: {lead_id}) deleted successfully"
            }
        }
    except Exception as e:
        return {
            "structuredContent": {
                "success": False,
                "error": str(e)
            }
        }

# List leads
@mcp.tool(annotations={"readOnlyHint": True})
def list_leads(limit: int = 10, offset: int = 0) -> dict:
    """Use this when user wants to see a list of leads."""
    conn = sqlite3.connect('crm.db')
    cursor = conn.cursor()
    cursor.execute('''
        SELECT id, first_name, last_name, email, phone, company, lead_source, created_at
        FROM leads
        ORDER BY created_at DESC
        LIMIT ? OFFSET ?
    ''', (limit, offset))
    rows = cursor.fetchall()

    # Get total count
    cursor.execute('SELECT COUNT(*) FROM leads')
    total = cursor.fetchone()[0]
    conn.close()

    leads = [
        {
            "id": row[0],
            "firstName": row[1],
            "lastName": row[2],
            "email": row[3],
            "phone": row[4],
            "company": row[5],
            "leadSource": row[6],
            "createdAt": row[7]
        }
        for row in rows
    ]

    return {
        "structuredContent": {
            "success": True,
            "leads": leads,
            "total": total,
            "limit": limit,
            "offset": offset
        }
    }

# Run server
if __name__ == "__main__":
    import uvicorn
    uvicorn.run(mcp.app, host="0.0.0.0", port=8000)

Code Solutions

Complete Widget Entry Point

// src/main.tsx
import React from 'react';
import ReactDOM from 'react-dom/client';
import { CreateLeadWidget } from './components/widgets/CreateLeadWidget';
import './index.css';

ReactDOM.createRoot(document.getElementById('root')!).render(
  <React.StrictMode>
    <CreateLeadWidget />
  </React.StrictMode>
);

Tailwind CSS Configuration

// tailwind.config.js
/** @type {import('tailwindcss').Config} */
export default {
  content: ['./index.html', './src/**/*.{js,ts,jsx,tsx}'],
  theme: {
    extend: {},
  },
  plugins: [],
};
/* src/index.css */
@tailwind base;
@tailwind components;
@tailwind utilities;

/* Custom styles for accessibility */
.sr-only {
  position: absolute;
  width: 1px;
  height: 1px;
  padding: 0;
  margin: -1px;
  overflow: hidden;
  clip: rect(0, 0, 0, 0);
  white-space: nowrap;
  border-width: 0;
}

/* Focus visible for keyboard navigation */
*:focus-visible {
  outline: 2px solid #3b82f6;
  outline-offset: 2px;
}

Build and Deploy Scripts

{
  "name": "crm-form-app",
  "version": "1.0.0",
  "scripts": {
    "dev": "vite",
    "build": "tsc && vite build",
    "preview": "vite preview",
    "lint": "eslint src --ext ts,tsx",
    "test": "vitest"
  }
}

Testing & Validation

Unit Tests for Form Validation

// src/schemas/__tests__/leadSchema.test.ts
import { describe, it, expect } from 'vitest';
import { leadSchema, leadSchemaWithContactMethod } from '../leadSchema';

describe('Lead Schema Validation', () => {
  it('should accept valid lead data', () => {
    const validLead = {
      firstName: 'John',
      lastName: 'Smith',
      email: 'john@example.com',
      phone: '+1234567890',
      company: 'Acme Corp',
      jobTitle: 'Manager',
      leadSource: 'website' as const,
      notes: 'Test notes',
    };

    const result = leadSchema.safeParse(validLead);
    expect(result.success).toBe(true);
  });

  it('should reject missing first name', () => {
    const invalidLead = {
      lastName: 'Smith',
      leadSource: 'website' as const,
    };

    const result = leadSchema.safeParse(invalidLead);
    expect(result.success).toBe(false);
    if (!result.success) {
      expect(result.error.issues[0].path).toEqual(['firstName']);
    }
  });

  it('should reject invalid email', () => {
    const invalidLead = {
      firstName: 'John',
      lastName: 'Smith',
      email: 'invalid-email',
      leadSource: 'website' as const,
    };

    const result = leadSchema.safeParse(invalidLead);
    expect(result.success).toBe(false);
  });

  it('should reject invalid phone number', () => {
    const invalidLead = {
      firstName: 'John',
      lastName: 'Smith',
      phone: '123', // Too short
      leadSource: 'website' as const,
    };

    const result = leadSchema.safeParse(invalidLead);
    expect(result.success).toBe(false);
  });

  it('should require at least one contact method', () => {
    const noContact = {
      firstName: 'John',
      lastName: 'Smith',
      leadSource: 'website' as const,
    };

    const result = leadSchemaWithContactMethod.safeParse(noContact);
    expect(result.success).toBe(false);
  });

  it('should accept email without phone', () => {
    const emailOnly = {
      firstName: 'John',
      lastName: 'Smith',
      email: 'john@example.com',
      leadSource: 'website' as const,
    };

    const result = leadSchemaWithContactMethod.safeParse(emailOnly);
    expect(result.success).toBe(true);
  });
});

Integration Tests for Widget

// src/components/widgets/__tests__/CreateLeadWidget.test.tsx
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import { CreateLeadWidget } from '../CreateLeadWidget';

describe('CreateLeadWidget', () => {
  beforeEach(() => {
    // Mock window.openai
    global.window.openai = {
      toolOutput: {
        prefill: {
          firstName: 'John',
          lastName: 'Smith',
          company: 'Acme Corp',
        },
      },
      callTool: vi.fn(),
      sendFollowUpMessage: vi.fn(),
    };
  });

  it('should pre-fill form with data from toolOutput', () => {
    render(<CreateLeadWidget />);

    expect(screen.getByDisplayValue('John')).toBeInTheDocument();
    expect(screen.getByDisplayValue('Smith')).toBeInTheDocument();
    expect(screen.getByDisplayValue('Acme Corp')).toBeInTheDocument();
  });

  it('should show validation errors for required fields', async () => {
    global.window.openai.toolOutput = { prefill: {} };
    render(<CreateLeadWidget />);

    const submitButton = screen.getByText('Create Lead');
    fireEvent.click(submitButton);

    await waitFor(() => {
      expect(screen.getByText('First name is required')).toBeInTheDocument();
      expect(screen.getByText('Last name is required')).toBeInTheDocument();
    });
  });

  it('should call create_lead tool on valid submission', async () => {
    render(<CreateLeadWidget />);

    // Fill in email (required contact method)
    const emailInput = screen.getByPlaceholderText(/john.smith@company.com/i);
    fireEvent.change(emailInput, { target: { value: 'john@example.com' } });

    // Select lead source
    const leadSourceSelect = screen.getByLabelText(/lead source/i);
    fireEvent.change(leadSourceSelect, { target: { value: 'website' } });

    // Submit form
    const submitButton = screen.getByText('Create Lead');
    fireEvent.click(submitButton);

    await waitFor(() => {
      expect(global.window.openai.callTool).toHaveBeenCalledWith(
        'create_lead',
        expect.objectContaining({
          first_name: 'John',
          last_name: 'Smith',
          company: 'Acme Corp',
          email: 'john@example.com',
          lead_source: 'website',
        })
      );
    });
  });
});

Manual Testing Checklist

# Form Testing Checklist

## Validation Testing
- [ ] Required fields show errors when empty
- [ ] Email validation rejects invalid formats
- [ ] Phone validation accepts E.164 format (+1234567890)
- [ ] Notes field limits to 1000 characters
- [ ] At least one contact method (email or phone) is required
- [ ] Character counters update in real-time

## Accessibility Testing
- [ ] All form fields have proper labels
- [ ] Error messages are announced by screen readers
- [ ] Keyboard navigation works (Tab, Enter)
- [ ] Focus indicators are visible
- [ ] ARIA attributes are correct
- [ ] Form works with screen reader (test with NVDA/VoiceOver)

## Pre-fill Testing
- [ ] Form pre-fills with data from toolInput
- [ ] Partial pre-fill works correctly
- [ ] Empty pre-fill doesn't break form

## Multi-Step Form Testing
- [ ] Progress indicator shows correct step
- [ ] Can navigate backward without losing data
- [ ] Validation prevents advancing with errors
- [ ] Final review step shows all data correctly
- [ ] Form state persists across widget reloads

## Error Handling
- [ ] Client-side validation errors display inline
- [ ] Server-side validation errors are shown
- [ ] Network errors are handled gracefully
- [ ] Success messages are clear

## Destructive Actions
- [ ] Delete lead requires confirmation
- [ ] Update lead does NOT require confirmation
- [ ] Create lead does NOT require confirmation

## Cross-Browser Testing
- [ ] Chrome
- [ ] Firefox
- [ ] Safari
- [ ] Edge
- [ ] Mobile browsers (iOS Safari, Chrome Android)

Common Pitfalls & Solutions

1. Form State Not Persisting

Problem: Form data is lost when user sends a message in ChatGPT.

Solution: Use widgetState to persist form data:

useEffect(() => {
  const subscription = watch((data) => {
    window.openai?.setWidgetState({ formData: data });
  });
  return () => subscription.unsubscribe();
}, [watch]);

2. Validation Mismatch

Problem: Client-side validation passes but server-side fails.

Solution: Share validation schema between client and server:

// Shared schema (if using TypeScript on backend)
export const leadSchema = z.object({
  // ... schema definition
});

// Or use Pydantic with matching rules

3. Missing Contact Method Validation

Problem: Form submits without email or phone.

Solution: Use .refine() for cross-field validation:

export const leadSchemaWithContactMethod = leadSchema.refine(
  (data) => data.email || data.phone,
  {
    message: 'At least one contact method is required',
    path: ['email'],
  }
);

4. Incorrect destructiveHint Usage

Problem: Create/update operations ask for confirmation.

Solution: Only use destructiveHint: true for permanent deletions:

# โœ“ CORRECT
@mcp.tool(annotations={"destructiveHint": False})
def create_lead(...):  # Create is NOT destructive
    pass

# โœ“ CORRECT
@mcp.tool(annotations={"destructiveHint": False})
def update_lead(...):  # Update preserves data
    pass

# โœ“ CORRECT
@mcp.tool(annotations={"destructiveHint": True})
def delete_lead(...):  # Delete IS destructive
    pass

5. Poor Error Messages

Problem: Validation errors are vague (โ€œInvalid inputโ€).

Solution: Provide specific, actionable error messages:

// โœ— BAD
email: z.string().email(),

// โœ“ GOOD
email: z.string().email('Please enter a valid email address (e.g., name@company.com)'),

6. Accessibility Issues

Problem: Screen readers canโ€™t navigate form.

Solution: Add proper ARIA attributes:

<Input
  id={fieldId}
  aria-label="Email Address"
  aria-required="true"
  aria-invalid={!!errors.email}
  aria-describedby={errors.email ? 'email-error' : undefined}
/>

7. Slow Form Validation

Problem: Form lags when validating on every keystroke.

Solution: Use mode: 'onBlur' or debounce validation:

const { register } = useForm({
  mode: 'onBlur', // Only validate when field loses focus
});

Extension Ideas

1. Duplicate Detection

Add duplicate detection based on email or phone:

@mcp.tool()
def check_duplicate(email: str = None, phone: str = None) -> dict:
    """Check if a lead with this email or phone already exists."""
    conn = sqlite3.connect('crm.db')
    cursor = conn.cursor()

    query = 'SELECT id, first_name, last_name FROM leads WHERE email=? OR phone=?'
    cursor.execute(query, (email, phone))
    duplicates = cursor.fetchall()
    conn.close()

    if duplicates:
        return {
            "structuredContent": {
                "isDuplicate": True,
                "matches": [
                    {
                        "id": row[0],
                        "name": f"{row[1]} {row[2]}"
                    }
                    for row in duplicates
                ]
            }
        }

    return {
        "structuredContent": {
            "isDuplicate": False
        }
    }

2. File Upload (Business Card Scan)

Add ability to upload business card images:

const handleFileUpload = async (file: File) => {
  // Upload file to ChatGPT
  const fileId = await window.openai?.uploadFile(file);

  // Call OCR tool to extract contact info
  window.openai?.callTool('extract_business_card', { fileId });
};

3. Auto-save Draft

Auto-save form as draft every 30 seconds:

useEffect(() => {
  const interval = setInterval(() => {
    const data = watch();
    if (isDirty) {
      window.openai?.callTool('save_draft', { data });
    }
  }, 30000); // Every 30 seconds

  return () => clearInterval(interval);
}, [watch, isDirty]);

4. Smart Field Suggestions

Suggest company based on email domain:

const handleEmailChange = async (email: string) => {
  const domain = email.split('@')[1];
  if (domain && !domain.includes('gmail') && !domain.includes('yahoo')) {
    // Lookup company by domain
    const response = await fetch(`https://api.clearbit.com/v1/domains/find?domain=${domain}`);
    const data = await response.json();
    if (data.name) {
      setValue('company', data.name);
    }
  }
};

5. Bulk Import

Add CSV import functionality:

@mcp.tool()
def import_leads_from_csv(file_content: str) -> dict:
    """Import multiple leads from CSV file."""
    import csv
    import io

    reader = csv.DictReader(io.StringIO(file_content))
    success_count = 0
    errors = []

    for row in reader:
        try:
            lead = Lead(**row)
            # Insert into database
            success_count += 1
        except Exception as e:
            errors.append(f"Row {reader.line_num}: {str(e)}")

    return {
        "structuredContent": {
            "success": True,
            "imported": success_count,
            "errors": errors
        }
    }

6. Custom Fields

Allow adding custom fields dynamically:

const [customFields, setCustomFields] = useState<Array<{name: string, value: string}>>([]);

const addCustomField = () => {
  setCustomFields([...customFields, { name: '', value: '' }]);
};

Performance Optimization

1. Lazy Load Heavy Components

import { lazy, Suspense } from 'react';

const MultiStepForm = lazy(() => import('./components/forms/MultiStepForm'));

function App() {
  return (
    <Suspense fallback={<div>Loading...</div>}>
      <MultiStepForm />
    </Suspense>
  );
}

2. Debounce Validation

import { useDebouncedCallback } from 'use-debounce';

const debouncedValidation = useDebouncedCallback(
  async (value) => {
    const isValid = await validateEmail(value);
    setError(isValid ? null : 'Invalid email');
  },
  500 // Wait 500ms after user stops typing
);

3. Optimize Re-renders

// Use React.memo for expensive components
const FormField = React.memo(({ label, error, children }) => {
  // ... component code
});

// Use useCallback for event handlers
const handleSubmit = useCallback(async (data) => {
  await onSubmit(data);
}, [onSubmit]);

4. Virtual Scrolling for Long Forms

import { FixedSizeList } from 'react-window';

function LongFormList({ fields }) {
  return (
    <FixedSizeList
      height={600}
      itemCount={fields.length}
      itemSize={80}
    >
      {({ index, style }) => (
        <div style={style}>
          <FormField {...fields[index]} />
        </div>
      )}
    </FixedSizeList>
  );
}

Security Considerations

1. Input Sanitization

import html

def sanitize_input(text: str) -> str:
    """Sanitize user input to prevent XSS attacks."""
    return html.escape(text.strip())

# Use in tool
def create_lead(first_name: str, ...):
    first_name = sanitize_input(first_name)
    # ... rest of code

2. SQL Injection Prevention

# โœ“ GOOD: Parameterized queries
cursor.execute('SELECT * FROM leads WHERE email = ?', (email,))

# โœ— BAD: String concatenation
cursor.execute(f"SELECT * FROM leads WHERE email = '{email}'")

3. Rate Limiting

from fastapi import HTTPException
from slowapi import Limiter, _rate_limit_exceeded_handler
from slowapi.util import get_remote_address

limiter = Limiter(key_func=get_remote_address)

@app.post("/create_lead")
@limiter.limit("10/minute")
async def create_lead_endpoint(...):
    # Only allow 10 lead creations per minute per IP
    pass

4. CSRF Protection

from fastapi_csrf_protect import CsrfProtect

@app.post("/create_lead")
async def create_lead_endpoint(
    csrf_protect: CsrfProtect = Depends()
):
    csrf_protect.validate_csrf(request)
    # ... rest of code

Real-World Applications

1. CRM Systems

  • Salesforce Integration: Create leads directly in Salesforce
  • HubSpot Forms: Submit HubSpot contact forms from ChatGPT
  • Customer Support: Create support tickets with form data

2. HR & Recruitment

  • Job Applications: Collect candidate information
  • Employee Onboarding: Multi-step onboarding forms
  • Time-off Requests: Submit vacation requests

3. E-Commerce

  • Product Returns: Return request forms
  • Custom Orders: Collect custom product specifications
  • Wholesale Inquiries: B2B lead generation forms

4. Healthcare

  • Patient Registration: Collect patient information
  • Appointment Scheduling: Book appointments with patient data
  • Symptom Checkers: Multi-step diagnostic forms

5. Education

  • Course Enrollment: Student registration forms
  • Assignment Submission: Submit assignments with metadata
  • Feedback Forms: Course evaluation surveys

Learning Outcomes

By completing this project, you have learned:

Technical Skills

  • Form validation with React Hook Form and Zod
  • Client-side vs server-side validation patterns
  • Multi-step form state management
  • Accessible form design with ARIA
  • Pre-filling forms from natural language input
  • Handling write operations in ChatGPT apps
  • Destructive action patterns with destructiveHint

Architecture Skills

  • Separation of concerns (UI, validation, business logic)
  • Reusable component design
  • Custom hooks for complex logic
  • Error boundary and error handling patterns
  • State persistence strategies

ChatGPT Apps Patterns

  • Reading data from toolInput for pre-filling
  • Calling tools from widgets with callTool()
  • Widget state persistence with setWidgetState()
  • Proper tool annotation (readOnlyHint, destructiveHint)
  • Two-step flow: show form โ†’ submit data

Resources & References

Documentation

Books

  • โ€œForm Design Patternsโ€ by Adam Silver
  • โ€œInclusive Componentsโ€ by Heydon Pickering
  • โ€œWeb Form Designโ€ by Luke Wroblewski

Articles

Tools

Code Examples


Next Steps

After completing this project, you can:

  1. Add OAuth Authentication (Project 6) - Protect your CRM with user authentication
  2. Build a Dashboard (Project 7) - Visualize lead data with charts
  3. Add E-Commerce Features (Project 8) - Convert leads to customers
  4. Submit to App Store (Project 9) - Publish your CRM app to 800M+ users

Congratulations! Youโ€™ve built a production-ready form-based data entry application for ChatGPT. You now understand how to handle write operations, validate data on both client and server, and create accessible, user-friendly forms that integrate seamlessly with conversational AI.