ChatGPT Widget Development: The Complete Guide to Building Interactive Widgets
ChatGPT apps are fundamentally different from traditional web applications. Instead of rendering full-page experiences, they render widgets - compact, focused components that live inside ChatGPT's interface and interact with the model in real-time.
This guide covers everything you need to build production-ready ChatGPT widgets that pass OpenAI's approval process and delight users with seamless interactions.
Table of Contents
- Widget Runtime Architecture
- Understanding window.openai API
- Display Modes: Inline, Fullscreen, and PiP
- React Component Patterns
- State Management Best Practices
- UI/UX Compliance Standards
- Code Examples & Working Demos
- Performance Optimization
- Accessibility & WCAG Compliance
- Common Pitfalls & Solutions
Widget Runtime Architecture
What is a ChatGPT Widget?
A ChatGPT widget is a React component that runs in the widget runtime - a sandboxed JavaScript environment embedded within ChatGPT's interface. The widget:
- Renders inline cards (2-5KB of structured content)
- Responds to user interactions (clicks, form inputs, selections)
- Communicates with MCP servers via ChatGPT's tool calling mechanism
- Updates state without full page refreshes
- Maintains context across multiple messages
Widget Runtime Lifecycle
When ChatGPT calls a tool and you return a widget:
1. Tool Handler (MCP Server)
├─ Returns structured content + widget HTML
│
2. ChatGPT Widget Runtime
├─ Mounts React component in sandboxed iframe
├─ Injects window.openai API
├─ Establishes bidirectional communication
│
3. Widget Component
├─ Initializes state (from widgetState prop)
├─ Attaches event listeners
├─ Renders interactive UI
│
4. User Interaction
├─ Click "Book Class" button
├─ Widget calls window.openai.setWidgetState(newState)
│
5. State Propagation
├─ ChatGPT detects state change
├─ Calls MCP tool with updated state
├─ Tool handler responds with new widget/message
├─ Widget re-renders with new data
Key Insight: The widget is not a full app. It's a lightweight UI component that orchestrates interactions between ChatGPT and your MCP server.
Structured Content Format
Every widget response follows MCP's three-part payload:
{
structuredContent: {
// Semantic data for ChatGPT model context (under 4k tokens)
type: "object",
properties: {
availableClasses: [...],
selectedClass: {...},
userPreferences: {...}
}
},
content: {
// User-facing text/explanation
text: "Here are available yoga classes for tomorrow..."
},
_meta: {
// Widget rendering instructions
widget: {
type: "inline",
mimeType: "text/html+skybridge",
data: "<div>...</div>"
}
}
}
This separation ensures:
- structuredContent keeps the model context-aware
- content provides fallback text (if widget doesn't render)
- _meta.widget contains the actual React widget code
For a deeper dive, see our guide on MCP Protocol Fundamentals: A Developer's Guide.
Understanding window.openai API
The window.openai API is your bridge from the widget to ChatGPT. It's the ONLY way widgets can communicate with the model and update state.
Core API Reference
1. window.openai.setWidgetState(state)
Purpose: Update widget state and trigger MCP tool call with new state
Signature:
interface WidgetState {
[key: string]: any;
}
window.openai.setWidgetState(state: WidgetState): void
Example:
// User clicks "Book Class" button
const handleBooking = async (classId) => {
const newState = {
selectedClassId: classId,
action: 'confirm_booking',
timestamp: new Date().toISOString()
};
// This triggers MCP tool call with updated state
window.openai.setWidgetState(newState);
};
How it works:
- Widget calls
setWidgetState(newState) - ChatGPT captures new state
- ChatGPT calls your MCP tool with the state change
- Tool handler processes the action
- Tool returns new widget (or message)
- Widget component re-mounts with new
widgetStateprop
Best Practice: Design your state to represent user intent not just data.
- ❌ Bad:
{ input: "Book class", isLoading: true } - ✅ Good:
{ action: 'book_class', classId: 123, confirmed: true }
2. window.openai.getWidgetState()
Purpose: Read current widget state (rarely needed)
Signature:
window.openai.getWidgetState(): WidgetState
Example:
// Check if user already selected a class before allowing booking
const currentState = window.openai.getWidgetState();
if (!currentState.selectedClassId) {
alert('Please select a class first');
return;
}
3. window.openai.useWidgetState() [Hook]
Purpose: React hook for state management (recommended pattern)
Signature:
const [state, setState] = window.openai.useWidgetState();
// setState triggers tool call (just like setWidgetState)
setState({ action: 'add_to_cart', itemId: 456 });
Example - Full Component:
function ClassBookingWidget({ widgetState }) {
const [state, setState] = window.openai.useWidgetState();
const [isLoading, setIsLoading] = useState(false);
const handleBooking = async (classId) => {
setIsLoading(true);
setState({ action: 'book_class', classId });
// MCP tool will handle the actual booking
};
return (
<div className="class-card">
<h3>{widgetState.selectedClass?.name}</h3>
<p>Time: {widgetState.selectedClass?.time}</p>
<button onClick={() => handleBooking(widgetState.selectedClass?.id)}>
{isLoading ? 'Booking...' : 'Confirm Booking'}
</button>
</div>
);
}
Why use the hook over setWidgetState?
- Integrates with React lifecycle
- Handles re-renders automatically
- Provides loading state awareness
- Cleaner component code
API Limitations & Constraints
| Limit | Value | Implication |
|---|---|---|
| Widget response size | < 4KB tokens | Keep structuredContent lean |
| State object size | < 100KB | Avoid large arrays in state |
| Tool calls per session | Unlimited | No per-session cap |
| Latency target | < 2 seconds | Keep tool handlers fast |
Important: Avoid storing large data in widgetState. Instead:
- Store IDs in state
- Fetch full data from your API
- Cache results for 5-10 minutes
For advanced state management patterns, see Building Stateful ChatGPT Apps with Server-Side Sessions.
Display Modes: Inline, Fullscreen, and PiP
ChatGPT supports three widget display modes. Choose based on use case:
1. Inline Mode
When to use: Single action, small data display, quick interactions
Characteristics:
- Rendered as card within chat message
- Max 2 primary CTAs per card
- No scrolling (height-constrained)
- Best for confirmations, selections, quick info
Example Use Cases:
- Book a class: Show class details + "Book Now" button
- Checkout: Show order summary + "Pay Now" button
- Appointment confirmation: Show time + "Confirm" button
Code:
{
_meta: {
widget: {
type: "inline",
mimeType: "text/html+skybridge",
data: `<div style="max-width:400px; padding:16px;">
<h3>Vinyasa Flow - Tomorrow 10:00 AM</h3>
<p>Spots available: 5</p>
<p>Instructor: Sarah M.</p>
<button onclick="window.openai.setWidgetState({action:'book'})">
Book Class
</button>
</div>`
}
}
}
Best Practices:
- Keep height under 300px
- Use 2 columns max for layout
- Single primary action (book, pay, confirm)
- Optional secondary action (view details, cancel)
2. Fullscreen Mode
When to use: Complex tasks, browsing, multi-step workflows, rich visualizations
Characteristics:
- Takes up full ChatGPT canvas
- Composer always visible at bottom
- Can have internal navigation
- Supports scrolling and full interactions
Example Use Cases:
- Property search: Browse listings with map, filters, details
- Menu browsing: Full restaurant menu with search
- Calendar view: Multi-week class schedule
- Dashboard: Analytics with charts and KPIs
Code:
{
_meta: {
widget: {
type: "fullscreen",
mimeType: "text/html+skybridge",
data: `<div style="display:flex; height:100%; flex-direction:column;">
<div style="flex:1; overflow:auto;">
<!-- Your complex UI here -->
<PropertyListings {...data} />
</div>
</div>`
}
}
}
Best Practices:
- Leave space for composer at bottom (50px)
- Implement proper scrolling (not nested)
- Use top navigation/breadcrumbs for context
- Provide quick exit (back button, close, or message)
3. Picture-in-Picture (PiP) Mode
When to use: Real-time activities that run parallel with chat (games, live collaboration, quizzes)
Characteristics:
- Floating window alongside chat
- User can minimize/expand
- Auto-closes when conversation ends
- Maintains state during chat
Example Use Cases:
- Collaborative whiteboard
- Live multiplayer game
- Interactive quiz
- Real-time data streaming (stock ticker)
Code:
{
_meta: {
widget: {
type: "pip",
mimeType: "text/html+skybridge",
data: `<div style="width:300px; height:400px;">
<!-- Live activity here -->
<Whiteboard {...data} />
</div>`
}
}
}
Best Practices:
- Minimize when not actively used
- Handle state loss gracefully
- Provide clear close button
- Update based on chat context
React Component Patterns
Pattern 1: Functional Component with Hooks
Recommended approach for new widgets
import React, { useState, useEffect } from 'react';
export default function ClassBookingWidget({ widgetState }) {
const [selectedClassId, setSelectedClassId] = useState(
widgetState?.selectedClassId || null
);
const [isConfirming, setIsConfirming] = useState(false);
const classes = widgetState?.availableClasses || [];
const handleSelectClass = (classId) => {
setSelectedClassId(classId);
};
const handleConfirmBooking = async () => {
setIsConfirming(true);
// Trigger MCP tool call with new state
window.openai.setWidgetState({
action: 'confirm_booking',
classId: selectedClassId,
timestamp: new Date().toISOString()
});
setIsConfirming(false);
};
if (!classes.length) {
return <p>No classes available</p>;
}
const selectedClass = classes.find(c => c.id === selectedClassId);
return (
<div className="booking-widget">
<h3>Select a Class</h3>
<div className="class-list">
{classes.map(cls => (
<div
key={cls.id}
className={`class-card ${selectedClassId === cls.id ? 'selected' : ''}`}
onClick={() => handleSelectClass(cls.id)}
>
<h4>{cls.name}</h4>
<p>{cls.time}</p>
<p className="instructor">with {cls.instructor}</p>
</div>
))}
</div>
{selectedClass && (
<button
onClick={handleConfirmBooking}
disabled={isConfirming}
>
{isConfirming ? 'Booking...' : 'Confirm Booking'}
</button>
)}
</div>
);
}
Key Points:
- Use
widgetStateas initial data source - Call
window.openai.setWidgetState()on user action - Handle loading/confirmation states
- Keep component focused (single responsibility)
Pattern 2: Custom Hook for Widget State
Abstraction pattern for reusable state logic
// useWidgetAction.js
export function useWidgetAction(action) {
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState(null);
const execute = useCallback(async (payload) => {
setIsLoading(true);
setError(null);
try {
window.openai.setWidgetState({
action,
...payload,
timestamp: new Date().toISOString()
});
} catch (err) {
setError(err.message);
setIsLoading(false);
}
}, [action]);
return { execute, isLoading, error };
}
// Usage in component
export default function BookingWidget({ widgetState }) {
const { execute: bookClass, isLoading } = useWidgetAction('book_class');
const handleBook = (classId) => {
bookClass({ classId, membershipId: widgetState.memberId });
};
return (
<button onClick={() => handleBook(123)} disabled={isLoading}>
{isLoading ? 'Booking...' : 'Book Now'}
</button>
);
}
Benefits:
- Reusable across components
- Consistent error handling
- Cleaner component code
- Easier testing
Pattern 3: Optimistic UI Updates
Improve perceived performance with optimistic updates
export default function CartWidget({ widgetState }) {
const [optimisticCart, setOptimisticCart] = useState(widgetState.items || []);
const handleAddToCart = (itemId) => {
// Optimistically update UI immediately
setOptimisticCart([
...optimisticCart,
{ id: itemId, status: 'adding' }
]);
// Trigger actual action
window.openai.setWidgetState({
action: 'add_to_cart',
itemId,
cartId: widgetState.cartId
});
// Note: When tool responds with new widget,
// widgetState will update with server data
};
return (
<div>
{optimisticCart.map(item => (
<div key={item.id} className={item.status === 'adding' ? 'loading' : ''}>
{item.name}
</div>
))}
<button onClick={() => handleAddToCart(456)}>Add Item</button>
</div>
);
}
Why This Works:
- Instant visual feedback to user
- No jarring delays
- Server data ultimately authoritative
- Fallback to server data on new widget render
State Management Best Practices
1. Distinguish Widget State from Component State
// ❌ Don't mix them
const [input, setInput] = useState(''); // Component state
window.openai.setWidgetState({ input }); // Triggers MCP call
// ✅ Separate concerns
const [input, setInput] = useState(''); // Local UI state (no MCP call)
const handleSearch = () => {
window.openai.setWidgetState({
action: 'search',
query: input // Send to MCP when intentional
});
};
Rule: Only call setWidgetState() when you want an MCP tool to execute. Don't sync every keystroke.
2. Avoid Storing Large Data in State
// ❌ Bad: Storing 10K items in state
window.openai.setWidgetState({
allClasses: [/* 10,000 items */]
});
// ✅ Good: Store references, fetch on demand
window.openai.setWidgetState({
selectedClassIds: [1, 2, 3], // Just IDs
classesPage: 1,
totalPages: 50
});
// Then in MCP tool, fetch full data based on IDs
3. Design State for Idempotency
// ❌ Bad: State represents transitions
{
action: 'book_class',
isProcessing: true
}
// ✅ Good: State represents the desired outcome
{
action: 'book_class',
classId: 123,
confirmed: true, // Idempotent - same result each time
timestamp: '2025-12-26T10:00:00Z'
}
Why: If network fails and MCP tool is called twice, idempotent state ensures same result. The tool can check if booking already exists.
For deeper patterns, see our guide on Building Stateful ChatGPT Apps with Server-Side Sessions.
UI/UX Compliance Standards
OpenAI's Apps SDK has strict UX requirements. Widgets failing these will be rejected during review.
Critical Requirements
1. System Fonts Only (WCAG AA Compliance)
/* ❌ REJECTED: Custom fonts */
body {
font-family: 'Poppins', sans-serif; /* Custom font */
}
/* ✅ APPROVED: System fonts */
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
}
Allowed System Fonts:
- iOS: San Francisco (SF Pro)
- Android: Roboto
- Web fallback: Segoe UI, system-ui
Why: Consistency across ChatGPT app ecosystem + accessibility + performance
2. Maximum 2 Primary CTAs Per Card
<!-- ❌ REJECTED: 4 buttons -->
<button>Book</button>
<button>View Details</button>
<button>Share</button>
<button>Cancel</button>
<!-- ✅ APPROVED: 1 primary + 1 secondary -->
<button class="primary">Book Class</button>
<button class="secondary">View Details</button>
CTA Hierarchy:
- Primary (1): Main action user came for
- Secondary (1): Optional alternative action
- Tertiary: Use links (not buttons) in text
3. No Nested Scrolling in Inline Widgets
/* ❌ REJECTED: Nested scrollable area */
.class-card {
height: 200px;
overflow-y: auto; /* NO! */
}
/* ✅ APPROVED: Let parent handle scrolling */
.class-card {
/* Remove overflow */
}
/* Parent (ChatGPT) handles scrolling naturally */
Implication: Design inline widgets to fit in ~250px height, or use fullscreen mode.
4. No Deep Navigation (More Than 1 Level)
// ❌ REJECTED: Multi-step navigation
/* Step 1: Select class */
<ClassSelector />
/* Step 2: Enter details */
<PersonalDetails />
/* Step 3: Review booking */
<BookingReview />
// ✅ APPROVED: Atomic single-screen action
<ClassBookingCard
classData={data}
onBook={handleBook}
/>
Design Approach:
- Inline widgets: Single purpose action (select + confirm)
- Fullscreen widgets: Multiple steps acceptable (but keep logical)
- PiP: Limited navigation (mostly display + interactions)
5. No Custom Styling of Form Inputs
/* ❌ REJECTED: Heavily styled inputs */
input {
background: linear-gradient(45deg, #ff6b6b, #ff8e72);
border: 3px solid #ff6b6b;
font-size: 20px;
}
/* ✅ APPROVED: Native styling */
input {
/* Use platform defaults */
/* Light theming acceptable, no gradients/custom styling */
}
Contrast & Accessibility (WCAG AA)
Every text and interactive element must meet WCAG AA contrast ratio of 4.5:1.
// Check contrast using WebAIM tool
// https://webaim.org/resources/contrastchecker/
const colors = {
text: '#0A0E27', // Navy
background: '#FFFFFF', // White
ratio: 18.5 // ✅ PASS WCAG AAA (even better!)
};
const problematic = {
text: '#718096', // Gray
background: '#E2E8F0', // Light gray
ratio: 2.3 // ❌ FAIL WCAG AA (need 4.5)
};
Alt Text for All Images
<!-- ❌ REJECTED -->
<img src="/class.jpg" />
<!-- ✅ APPROVED -->
<img src="/class.jpg" alt="Instructor Sarah leading a vinyasa flow class" />
Code Examples & Working Demos
Complete Fitness Class Booking Widget
import React, { useState } from 'react';
import './ClassBooking.css';
export default function ClassBookingWidget({ widgetState }) {
const [selectedClassId, setSelectedClassId] = useState(null);
const [isConfirming, setIsConfirming] = useState(false);
const classes = widgetState?.availableClasses || [];
const selectedClass = classes.find(c => c.id === selectedClassId);
const handleConfirm = () => {
if (!selectedClassId) return;
setIsConfirming(true);
window.openai.setWidgetState({
action: 'book_class',
classId: selectedClassId,
memberId: widgetState.memberId
});
};
return (
<div className="class-booking-widget">
<h3 style={{ fontSize: '18px', fontWeight: '600', marginBottom: '12px' }}>
Available Classes Tomorrow
</h3>
<div className="class-list">
{classes.map(cls => (
<div
key={cls.id}
className={`class-item ${selectedClassId === cls.id ? 'selected' : ''}`}
onClick={() => setSelectedClassId(cls.id)}
role="button"
tabIndex={0}
aria-label={`${cls.name} at ${cls.time} with ${cls.instructor}`}
>
<div className="class-time">
<strong>{cls.time}</strong>
</div>
<div className="class-info">
<h4>{cls.name}</h4>
<p>with {cls.instructor}</p>
<p className="spots">
{cls.spotsAvailable} spot{cls.spotsAvailable !== 1 ? 's' : ''} available
</p>
</div>
{selectedClassId === cls.id && (
<div className="checkmark">✓</div>
)}
</div>
))}
</div>
{selectedClass && (
<button
onClick={handleConfirm}
disabled={isConfirming}
className="confirm-btn"
aria-label={`Confirm booking for ${selectedClass.name}`}
>
{isConfirming ? 'Confirming...' : 'Confirm Booking'}
</button>
)}
</div>
);
}
CSS:
.class-booking-widget {
padding: 16px;
max-width: 100%;
}
.class-list {
display: flex;
flex-direction: column;
gap: 8px;
margin-bottom: 16px;
}
.class-item {
display: flex;
gap: 12px;
padding: 12px;
border: 1px solid #E2E8F0;
border-radius: 8px;
cursor: pointer;
transition: all 0.2s;
background: #FFFFFF;
}
.class-item:hover {
border-color: #D4AF37;
background: #FFFBF7;
}
.class-item.selected {
border-color: #D4AF37;
background: #FFFBF7;
box-shadow: 0 0 0 2px rgba(212, 175, 55, 0.1);
}
.class-time {
flex: 0 0 50px;
font-size: 14px;
color: #0A0E27;
}
.class-info {
flex: 1;
}
.class-info h4 {
margin: 0;
font-size: 14px;
font-weight: 600;
color: #0A0E27;
}
.class-info p {
margin: 4px 0 0;
font-size: 12px;
color: #718096;
}
.spots {
font-weight: 500;
color: #1D4F37;
}
.checkmark {
color: #1D4F37;
font-weight: bold;
font-size: 18px;
}
.confirm-btn {
width: 100%;
padding: 12px;
background: #D4AF37;
color: #0A0E27;
border: none;
border-radius: 6px;
font-weight: 600;
cursor: pointer;
font-family: system-ui;
transition: all 0.2s;
}
.confirm-btn:hover:not(:disabled) {
background: #C7A032;
transform: translateY(-2px);
}
.confirm-btn:disabled {
opacity: 0.6;
cursor: not-allowed;
}
MCP Tool Handler (Node.js)
// tools.js - MCP Server
export const classBookingTool = {
name: 'book_class',
description: 'Book a fitness class for the user',
inputSchema: {
type: 'object',
properties: {
classId: { type: 'number', description: 'ID of class to book' },
memberId: { type: 'string', description: 'Member ID' }
},
required: ['classId', 'memberId']
},
async handler(params, context) {
const { classId, memberId } = params;
try {
// 1. Validate booking
const booking = await validateBooking(memberId, classId);
if (!booking.success) {
return {
structuredContent: { error: booking.reason },
content: `Sorry, I couldn't book the class: ${booking.reason}`,
};
}
// 2. Create booking in database
const result = await database.bookings.create({
memberId,
classId,
bookedAt: new Date(),
status: 'confirmed'
});
// 3. Return confirmation widget
const classData = await database.classes.get(classId);
return {
structuredContent: {
bookingId: result.id,
classId,
className: classData.name,
time: classData.time,
instructor: classData.instructor,
status: 'confirmed'
},
content: `✅ You're booked for ${classData.name} at ${classData.time} with ${classData.instructor}!`,
_meta: {
widget: {
type: 'inline',
mimeType: 'text/html+skybridge',
data: renderConfirmationWidget(result, classData)
}
}
};
} catch (error) {
return {
content: 'An error occurred while booking the class. Please try again.',
structuredContent: { error: error.message }
};
}
}
};
function renderConfirmationWidget(booking, classData) {
return `
<div style="padding:16px; background:#F7FDF5; border-radius:8px;">
<h3 style="margin:0; color:#1D4F37;">✓ Booking Confirmed</h3>
<p style="margin:8px 0; font-size:14px; color:#0A0E27;">
<strong>${classData.name}</strong><br/>
${classData.time}<br/>
Instructor: ${classData.instructor}
</p>
<p style="margin:0; font-size:12px; color:#718096;">
Confirmation #${booking.id}
</p>
</div>
`;
}
Performance Optimization
1. Keep Widget Responses Under 4K Tokens
// ❌ BAD: 8KB response
{
structuredContent: {
allClassesForYear: [/* 10,000 items */], // Way too much!
allInstructors: [/* 50 items */],
userHistory: [/* 500 items */]
}
}
// ✅ GOOD: Lean response
{
structuredContent: {
availableClassesToday: [/* 5 items */],
selectedClass: { id: 123, ... },
userMembershipStatus: 'active'
}
}
2. Lazy Load Non-Essential Data
// In React component
export default function ClassBrowser({ widgetState }) {
const [expandedClassId, setExpandedClassId] = useState(null);
const handleExpandClass = (classId) => {
// Only request details when user clicks
window.openai.setWidgetState({
action: 'show_class_details',
classId
});
setExpandedClassId(classId);
};
return (
<div>
{widgetState.classes.map(cls => (
<div key={cls.id}>
<h4>{cls.name}</h4>
<button onClick={() => handleExpandClass(cls.id)}>
View Details
</button>
</div>
))}
</div>
);
}
3. Memoize Components to Prevent Re-renders
import React, { memo, useCallback } from 'react';
// Memoize to prevent re-render when parent updates
const ClassCard = memo(({ classData, onSelect }) => {
return (
<div onClick={() => onSelect(classData.id)}>
<h4>{classData.name}</h4>
<p>{classData.time}</p>
</div>
);
});
export default function ClassList({ classes, onSelectClass }) {
// Use useCallback to maintain stable reference
const handleSelect = useCallback((classId) => {
window.openai.setWidgetState({
action: 'select_class',
classId
});
onSelectClass(classId);
}, []);
return (
<div>
{classes.map(cls => (
<ClassCard
key={cls.id}
classData={cls}
onSelect={handleSelect}
/>
))}
</div>
);
}
For comprehensive performance optimization, see Optimizing ChatGPT Widget Performance: Core Web Vitals for Iframes.
Accessibility & WCAG Compliance
1. Semantic HTML
<!-- ❌ Not accessible -->
<div onClick="handleClick">Click me</div>
<!-- ✅ Accessible -->
<button onClick="handleClick" aria-label="Select this class">
Click me
</button>
2. ARIA Labels for Interactive Elements
<div
onClick={() => selectClass(123)}
role="button"
tabIndex={0}
aria-label="Select Vinyasa Flow class at 10:00 AM"
onKeyPress={(e) => e.key === 'Enter' && selectClass(123)}
>
Vinyasa Flow
</div>
3. Focus Management
export default function Modal({ isOpen, onClose }) {
const closeButtonRef = useRef(null);
useEffect(() => {
if (isOpen) {
closeButtonRef.current?.focus();
}
}, [isOpen]);
if (!isOpen) return null;
return (
<div role="dialog" aria-modal="true">
<h2>Booking Confirmation</h2>
<p>Are you sure?</p>
<button ref={closeButtonRef} onClick={onClose}>
Close
</button>
</div>
);
}
4. Color Contrast Testing
| Element | Foreground | Background | Ratio | WCAG |
|---|---|---|---|---|
| Heading | #0A0E27 (Navy) | #FFFFFF (White) | 18.5 | AAA ✅ |
| Body text | #0A0E27 | #FFFFFF | 18.5 | AAA ✅ |
| Link | #D4AF37 (Gold) | #FFFFFF | 3.7 | AA ❌ (need 4.5) |
Solution: Add darker background or lighter text for links.
Common Pitfalls & Solutions
Pitfall 1: Calling setWidgetState on Every Change
// ❌ WRONG: Calls MCP on every keystroke
<input
value={searchQuery}
onChange={(e) => {
setSearchQuery(e.target.value);
window.openai.setWidgetState({ // ← BAD!
action: 'search',
query: e.target.value
});
}}
/>
// ✅ CORRECT: Only call when user intends action
<input
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
/>
<button onClick={() => window.openai.setWidgetState({
action: 'search',
query: searchQuery
})}>
Search
</button>
Pitfall 2: Storing Sensitive Data in Widget State
// ❌ WRONG: API key in state
window.openai.setWidgetState({
apiKey: 'sk-abc123...', // ← SECURITY RISK!
token: 'user-secret-token'
});
// ✅ CORRECT: Use server-side authentication
// In MCP server, validate request with auth header
// Never expose secrets to client
window.openai.setWidgetState({
action: 'fetch_data',
userId: '456' // ← OK (public ID)
});
Pitfall 3: Not Handling Error States
// ❌ WRONG: No error handling
const handleBook = () => {
window.openai.setWidgetState({ action: 'book_class', classId: 123 });
};
// ✅ CORRECT: Handle success and error
const [error, setError] = useState(null);
const handleBook = () => {
setError(null);
window.openai.setWidgetState({ action: 'book_class', classId: 123 });
// MCP tool should return error in structured content if booking fails
// Component re-renders with new widgetState.error
};
// Then in render:
{error && <div className="error">{error}</div>}
Pitfall 4: Ignoring Mobile Responsiveness
/* ❌ WRONG: Fixed width, breaks on mobile */
.widget {
width: 500px;
}
/* ✅ CORRECT: Responsive design */
.widget {
width: 100%;
max-width: 500px;
padding: 0 12px;
}
.class-item {
display: flex;
flex-direction: column; /* Stack on mobile */
}
@media (min-width: 480px) {
.class-item {
flex-direction: row; /* Side-by-side on desktop */
}
}
Pitfall 5: Over-Engineering Widget Components
Many developers treat widgets like full-featured web apps. They're not. Keep them simple and focused:
// ❌ OVER-ENGINEERED: 500+ lines, Redux, multiple views
class ComplexWidget extends React.Component {
state = { /* 20+ properties */ };
methods = { /* 15+ handlers */ };
render() { /* 200+ lines */ }
}
// ✅ SIMPLE & FOCUSED: 80 lines, single purpose
export default function ClassWidget({ widgetState }) {
const [selected, setSelected] = useState(null);
return (
<div>
{widgetState.classes.map(cls => (
<div key={cls.id} onClick={() => setSelected(cls.id)}>
{cls.name}
</div>
))}
<button onClick={() => window.openai.setWidgetState({
action: 'book',
classId: selected
})}>
Book
</button>
</div>
);
}
Why: Simpler code = fewer bugs = faster performance = easier approval.
Widget Testing & Quality Assurance
Testing with MCP Inspector
MCP Inspector is your primary development tool for testing widgets locally:
# Start MCP Inspector for your local server
npx @modelcontextprotocol/inspector@latest http://localhost:3000/mcp
# Then in ChatGPT developer mode:
# 1. Add connector: http://localhost:3000/mcp
# 2. Test tools and widgets in real ChatGPT conversation
# 3. Check widget rendering, state management, errors
What to Test:
- ✅ Widget renders correctly
- ✅ Buttons and clicks work
- ✅
setWidgetState()triggers MCP tool - ✅ New widget renders with updated state
- ✅ Mobile responsive (use DevTools)
- ✅ Contrast ratios pass WCAG AA
- ✅ No console errors/warnings
Common Testing Scenarios
Scenario 1: Happy Path (Success Case)
User: "Show me available yoga classes"
→ MCP tool returns widget with 3 classes
→ User clicks a class
→ Widget calls setWidgetState
→ MCP tool confirms booking
→ Confirmation widget renders
→ Expected: 5-10 second end-to-end flow
Scenario 2: Error Handling
User: "Book a class"
→ Widget calls setWidgetState with classId
→ MCP tool fails (class full, authentication error, etc.)
→ Tool returns error in structuredContent
→ Widget re-renders with error message
→ Expected: User understands what went wrong
Scenario 3: Network Latency
User clicks "Book Now"
→ Widget shows loading state
→ Network delay (intentional 3+ second delay)
→ MCP tool eventually responds
→ Widget shows confirmation
→ Expected: User doesn't click again (state prevents double-booking)
Browser DevTools Testing
Use Chrome/Firefox DevTools to validate before submitting:
// In Console, test widget state
window.openai.getWidgetState(); // Should return current state
window.openai.setWidgetState({ test: true }); // Should trigger MCP call
// Test contrast ratios
// Go to Lighthouse tab → Run audit → Check "Accessibility"
// Should score 90+
// Check performance
// Go to Performance tab → Record → Interact with widget → Stop
// Look for:
// - Long tasks > 50ms? (Try to optimize)
// - Smooth 60fps interactions? (Good!)
// - Initial paint < 1s? (Good!)
Widget State Management at Scale
Example: E-Commerce Shopping Widget
As widgets get more complex, state management becomes critical. Here's a real-world example:
// ecommerceWidget.js
import React, { useState, useCallback, useMemo } from 'react';
export default function EcommerceWidget({ widgetState }) {
// Local state: UI interactions only
const [expandedProductId, setExpandedProductId] = useState(null);
const [viewMode, setViewMode] = useState('grid'); // 'grid' or 'list'
// Derived state: don't store, compute from widgetState
const cartTotal = useMemo(() => {
return widgetState.cartItems?.reduce((sum, item) =>
sum + (item.price * item.quantity), 0
) || 0;
}, [widgetState.cartItems]);
// Handlers: update widget state ONLY on intentional user actions
const handleAddToCart = useCallback((productId, quantity = 1) => {
window.openai.setWidgetState({
action: 'add_to_cart',
productId,
quantity,
cartId: widgetState.cartId
});
}, [widgetState.cartId]);
const handleRemoveFromCart = useCallback((itemId) => {
window.openai.setWidgetState({
action: 'remove_from_cart',
itemId,
cartId: widgetState.cartId
});
}, [widgetState.cartId]);
const handleCheckout = useCallback(() => {
window.openai.setWidgetState({
action: 'proceed_to_checkout',
cartTotal,
itemCount: widgetState.cartItems?.length || 0
});
}, [cartTotal, widgetState.cartItems?.length]);
return (
<div className="ecommerce-widget">
<header className="widget-header">
<h3>Your Shopping Cart</h3>
<span className="cart-count">{widgetState.cartItems?.length || 0} items</span>
</header>
<div className="cart-items">
{widgetState.cartItems?.map(item => (
<div key={item.id} className="cart-item">
<div className="item-info">
<h4>{item.name}</h4>
<p className="price">${item.price}</p>
<p className="quantity">Qty: {item.quantity}</p>
</div>
<button
onClick={() => handleRemoveFromCart(item.id)}
aria-label={`Remove ${item.name} from cart`}
className="remove-btn"
>
✕
</button>
</div>
))}
</div>
<div className="cart-summary">
<p className="total">Total: ${cartTotal.toFixed(2)}</p>
<button
onClick={handleCheckout}
disabled={!widgetState.cartItems?.length}
className="checkout-btn"
>
Proceed to Checkout
</button>
</div>
</div>
);
}
Key Principles:
- ✅ Local state for UI only (expandedProductId, viewMode)
- ✅ Derived state for computed values (cartTotal)
- ✅ Widget state for user intent (action, productId, cartId)
- ✅ useCallback to prevent unnecessary re-renders
- ✅ Clear separation of concerns
Avoiding Common State Mistakes
// ❌ WRONG: Storing full product objects
widgetState = {
cart: [
{ id: 1, name: "...", price: 99, category: "...", ... }, // 100+ bytes
{ id: 2, name: "...", price: 49, category: "...", ... }
]
};
// ✅ CORRECT: Store minimal references
widgetState = {
cart: [
{ id: 1, quantity: 1 }, // 20 bytes
{ id: 2, quantity: 2 }
],
// MCP server provides full product data
availableProducts: [/* fetched based on IDs */]
};
Advanced Widget Patterns
Pattern: Multi-Step Wizard with Single Widget
Some workflows require multiple steps. Instead of returning different widgets, use state to track progress:
export default function BookingWizard({ widgetState }) {
// Steps: 1=SelectClass, 2=EnterDetails, 3=Confirm, 4=Done
const currentStep = widgetState.bookingStep || 1;
const handleNext = (data) => {
window.openai.setWidgetState({
action: 'booking_next_step',
step: currentStep + 1,
...data
});
};
return (
<div className="wizard">
{currentStep === 1 && (
<ClassSelector onSelect={(id) => handleNext({ classId: id })} />
)}
{currentStep === 2 && (
<PersonalDetails onSubmit={(details) => handleNext(details)} />
)}
{currentStep === 3 && (
<ReviewBooking
data={widgetState}
onConfirm={() => handleNext({ confirmed: true })}
/>
)}
{currentStep === 4 && (
<ConfirmationScreen bookingId={widgetState.bookingId} />
)}
</div>
);
}
Trade-offs:
- ✅ Single widget = faster initial render
- ✅ Maintains context across steps
- ❌ Slightly larger component
- ❌ Less modular
When to use: Multi-step flows with shared state (booking details, payment info)
Pattern: Fallback Content for Non-Widget Environments
Widgets might fail to render. Always provide text fallback:
function renderClassWidget(classData) {
const fallbackText = `
${classData.name} - ${classData.time}
Instructor: ${classData.instructor}
Spots: ${classData.spotsAvailable}
`;
return {
content: fallbackText, // ← Fallback if widget fails
_meta: {
widget: {
type: 'inline',
mimeType: 'text/html+skybridge',
data: `<div>...</div>`
}
}
};
}
Measuring Widget Success
Key Metrics for ChatGPT Widgets
| Metric | Why It Matters | Target |
|---|---|---|
| Widget Load Time | Perceived performance | < 1 second |
| First Paint | How fast widget appears | < 500ms |
| Time to Interactive | When user can click | < 2 seconds |
| Click Response | How fast button clicks respond | < 500ms |
| Error Rate | How often widget fails | < 0.5% |
| CTA Completion | % of users who finish action | > 50% |
| Approval Status | OpenAI acceptance | ✅ Approved |
Tools for Measurement
ChatGPT Native Analytics:
- Dashboard shows: Tool call count, error rate, latency
- Monitor via OpenAI Developer Dashboard
Custom Analytics (Recommended):
// In MCP tool handler, log events
analytics.track('class_booking_widget_shown', {
classCount: widgetState.availableClasses.length,
timestamp: new Date(),
userId: context.userId
});
analytics.track('class_booked', {
classId: params.classId,
bookingTime: Date.now() - startTime
});
For comprehensive guidance, see Analytics Tracking for ChatGPT Apps (GA4 Integration).
Widget Deployment & Monitoring
Pre-Deployment Checklist
✅ Widget Development:
[ ] React component complete and tested
[ ] All required props documented
[ ] Responsive design verified (mobile, tablet, desktop)
[ ] Accessibility audit passed (WCAG AA)
[ ] No console errors or warnings
[ ] Performance optimized (< 4KB tokens)
✅ MCP Server:
[ ] Tool handler complete
[ ] Error cases handled
[ ] Response structure valid
[ ] Widget rendering function correct
[ ] Tested with MCP Inspector
✅ OpenAI Compliance:
[ ] Widget fits display mode (inline < 300px height)
[ ] Maximum 2 CTAs per card
[ ] No custom fonts
[ ] System fonts only (SF Pro, Roboto)
[ ] WCAG AA contrast ratios
[ ] No nested scrolling
[ ] No ads or upsells
✅ Testing:
[ ] Happy path tested (success case)
[ ] Error cases tested
[ ] Mobile tested (Safari, Chrome mobile)
[ ] Accessibility tested (keyboard, screen reader)
[ ] Latency acceptable (< 2s)
[ ] Double-click protection (idempotent)
Post-Deployment Monitoring
After launching your ChatGPT app, monitor:
- Tool Call Metrics: How often is the tool being used?
- Error Rates: Are failures happening? What's the error pattern?
- Latency: Are responses taking too long?
- User Feedback: Check ChatGPT App Store reviews
- Approval Status: Did it pass OpenAI review?
For a comprehensive troubleshooting guide, see 5 Common OpenAI Approval Mistakes (And How to Avoid Them).
Next Steps
Now that you understand widget architecture and the window.openai API, you're ready to:
- Build your first widget using our Class Booking ChatGPT App template
- Deploy to production with our Deploying ChatGPT Apps to Production guide
- Optimize for approval using our OpenAI Approval Checklist
Browse Widget Templates
MakeAIHQ provides 50+ production-ready templates you can customize:
- Fitness Class Booking Widget
- Restaurant Menu Browser Widget
- E-commerce Product Search Widget
- Real Estate Property Search Widget
- Healthcare Appointment Booking Widget
Start Building Now
Ready to create your ChatGPT app? Try MakeAIHQ's AI Generator:
Generate Your App in 5 Minutes →
No coding required. Deploy to ChatGPT Store in 48 hours.
Related Articles
Widget Development Deep Dives
- Understanding window.openai: The Complete API Reference
- Building Real-Time Updates with Firebase in ChatGPT Apps
- Implementing Stripe Checkout in ChatGPT Widgets
- Handling File Uploads in ChatGPT App Widgets
React Component Patterns
- React Hooks for ChatGPT Widgets: Best Practices
- State Management for ChatGPT Apps
- Error Handling in ChatGPT Widget Components
- Performance Optimization: Memoization & Lazy Loading
UI/UX & Compliance
- WCAG AA Accessibility for ChatGPT Widgets
- OpenAI Design System Compliance
- Mobile Responsiveness in ChatGPT Widgets
- 5 Common OpenAI Approval Mistakes (And How to Avoid Them)
MCP Server Integration
- MCP Server Development: Beginner to Production
- Tool Metadata Optimization for Maximum Discoverability
- Structured Content Design Patterns
- Error Handling in MCP Servers: Best Practices
Case Studies
- Building a Class Booking ChatGPT App for Fitness Studios
- Building a Menu Browsing ChatGPT App for Restaurants
- Multi-Tool Composition Strategies for Complex Workflows
External Resources
- OpenAI Apps SDK Documentation
- Model Context Protocol Specification
- OpenAI Apps SDK Examples GitHub
- React Documentation
- WCAG 2.1 Accessibility Guidelines
- WebAIM Contrast Checker
- MDN Web Docs: HTML Accessibility
Key Takeaways
✅ Widgets are lightweight UI components, not full applications ✅ window.openai API bridges widgets and MCP servers ✅ Choose display mode based on complexity (inline < fullscreen < PiP) ✅ React hooks provide clean state management ✅ UI/UX compliance is non-negotiable for OpenAI approval ✅ Performance optimization keeps responses under 4K tokens ✅ Accessibility benefits all users, not just those with disabilities
Master these patterns and you'll be building production-ready ChatGPT widgets that delight users and pass OpenAI's approval process.
Ready to start building? Try MakeAIHQ's Widget Templates →
Want step-by-step guidance? Browse Our Complete Widget Development Guides →
Questions about widgets? Read Our Comprehensive FAQ →