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
- Project Overview
- Theoretical Foundation
- Solution Architecture
- Step-by-Step Implementation
- Code Solutions
- Testing & Validation
- Common Pitfalls & Solutions
- Extension Ideas
- Performance Optimization
- Security Considerations
- Real-World Applications
- Learning Outcomes
- 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:
- Write Operations in ChatGPT Apps: Most apps are read-only; this teaches data creation and modification
- Validation Patterns: Client-side vs server-side validation strategies
- User Confirmation Flows: How to safely handle destructive actions
- Form Accessibility: Building inclusive forms that work for all users
- 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
destructiveHintannotations - Build multi-step form flows with state management
- Create accessible forms with proper ARIA attributes
- Pre-fill forms from
toolInputdata - 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
toolInputfor 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
- MCP Inspector - Test MCP servers
- WAVE - Web accessibility evaluation tool
- axe DevTools - Accessibility testing
Code Examples
Next Steps
After completing this project, you can:
- Add OAuth Authentication (Project 6) - Protect your CRM with user authentication
- Build a Dashboard (Project 7) - Visualize lead data with charts
- Add E-Commerce Features (Project 8) - Convert leads to customers
- 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.