Project 2: Hello World Widget - Complete Guide
Project 2: Hello World Widget - Complete Guide
Build your first interactive ChatGPT widget: Display data from a tool call, use the window.openai API, and create a complete end-to-end ChatGPT app.
1. Project Overview
What Youโll Build
A weather widget that displays real-time weather data in ChatGPTโs conversation interface. When a user asks about weather, your MCP server will return structured data, and ChatGPT will render your custom React widget in an iframe, showing:
- Current temperature, conditions, and weather metrics
- Interactive buttons to refresh data or request forecasts
- Communication between the widget and ChatGPT via
window.openaiAPI - A complete understanding of the data flow from MCP tool โ ChatGPT โ Widget
Visual outcome:
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ ChatGPT โ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโค
โ โ
โ You: What's the weather in Tokyo? โ
โ โ
โ ChatGPT: Let me check the weather for Tokyo. โ
โ โ
โ โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ โ
โ โ ๐ค๏ธ Weather in Tokyo โ โ
โ โ โ โ
โ โ Temperature: 15ยฐC โ โ
โ โ Condition: Partly Cloudy โ โ
โ โ Humidity: 72% โ โ
โ โ Wind: 8 km/h E โ โ
โ โ โ โ
โ โ [๐ Refresh] [๐ Change City] [๐ 5-Day Forecast] โ โ
โ โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ โ
โ โ
โ The current weather in Tokyo is 15ยฐC and partly cloudy. โ
โ โ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
Why This Project Matters
This is the โHello Worldโ of ChatGPT Apps. Before diving into complex apps, you need to understand:
- How widgets are built and bundled using React and Vite
- How the
window.openaiAPI works for reading data and triggering actions - How widgets render in ChatGPTโs iframe with sandbox constraints
- How to connect a widget to your MCP server from Project 1
Every ChatGPT app you build will use these patterns. Master this project, and youโll understand the foundation of the entire platform.
Prerequisites
- Project 1 completed: You have a working MCP server with tools
- Basic React knowledge: Components, hooks (useState, useEffect), JSX
- Basic TypeScript: Type annotations, interfaces
- Node.js installed: v18 or higher
- Understanding of REST APIs: How data flows between client and server
Time Estimate
Weekend (6-8 hours)
- Hour 1-2: Set up Vite project and understand the build pipeline
- Hour 3-4: Build the weather widget component
- Hour 5-6: Connect widget to MCP server and test data flow
- Hour 7-8: Add interactive features and polish
2. Theoretical Foundation
What Are Web Components in ChatGPT?
A web component in the ChatGPT Apps context is an HTML document (with embedded CSS and JavaScript) that renders inside an iframe within the ChatGPT conversation. Think of it as a mini web application that:
- Runs in a sandboxed environment (iframe with limited permissions)
- Receives data from your MCP server via the
window.openai.toolOutputproperty - Can communicate back to ChatGPT through the
window.openaiAPI - Must be self-contained (single HTML file with all assets inlined)
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ ChatGPT Web UI โ
โ โ
โ โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ โ
โ โ Chat Messages โ โ
โ โ User: What's the weather? โ โ
โ โ ChatGPT: Let me check... โ โ
โ โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ โ
โ โ
โ โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ โ
โ โ IFRAME (Your Widget) โ โ
โ โ โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ โ โ
โ โ โ Your React App โ โ โ
โ โ โ - Reads: window.openai.toolOutput โ โ โ
โ โ โ - Calls: window.openai.callTool() โ โ โ
โ โ โ - Sends: window.openai.sendFollowUp() โ โ โ
โ โ โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ โ โ
โ โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ โ
โ โ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
The window.openai API
ChatGPT injects a global window.openai object into your widgetโs iframe. This is your communication bridge to ChatGPT. Here are the key APIs:
Reading Data
// The data your MCP server returned
window.openai.toolOutput: any
// The parameters that were passed to the tool
window.openai.toolInput: any
// Example:
const weatherData = window.openai?.toolOutput;
// {
// temperature: 15,
// condition: "Partly Cloudy",
// humidity: 72,
// wind: "8 km/h E",
// city: "Tokyo"
// }
Triggering Actions
// Call another MCP tool
window.openai.callTool(
toolName: string,
parameters: Record<string, any>
): Promise<void>
// Example:
window.openai?.callTool('get_weather', {
city: 'Paris',
units: 'celsius'
});
// Send a message as if the user typed it
window.openai.sendFollowUpMessage(
message: string
): void
// Example:
window.openai?.sendFollowUpMessage(
'Show me the 5-day forecast for Tokyo'
);
State Management
// Get persisted state (survives across conversation turns)
window.openai.widgetState: any
// Save state
window.openai.setWidgetState(
state: any
): void
// Example:
window.openai?.setWidgetState({
lastCity: 'Tokyo',
units: 'celsius',
favoriteLocations: ['Tokyo', 'London', 'New York']
});
UI Controls
// Expand widget to fullscreen
window.openai.requestFullscreen(): void
// Exit fullscreen
window.openai.exitFullscreen(): void
// Example:
<button onClick={() => window.openai?.requestFullscreen()}>
Expand
</button>
File Handling
// Upload a file (image, document)
window.openai.uploadFile(
file: File
): Promise<{ fileId: string }>
// Get download URL for a file
window.openai.getFileDownloadUrl(
fileId: string
): Promise<string>
Host Communication Pattern
The communication between your widget and ChatGPT follows a specific pattern:
1. USER ASKS QUESTION
โ
2. ChatGPT calls your MCP tool
โ
3. MCP server returns data + widget URL
{
"structuredContent": { /* data */ },
"uiComponent": {
"type": "iframe",
"url": "https://your-app.com/widget.html"
}
}
โ
4. ChatGPT loads your widget in iframe
โ
5. Widget reads window.openai.toolOutput
โ
6. User clicks button in widget
โ
7. Widget calls window.openai.callTool()
โ
8. Back to step 2 (cycle continues)
Key insight: Your widget is reactive. It doesnโt fetch data directlyโit reads what ChatGPT provides via toolOutput and requests new data by calling tools.
Sandbox Constraints and Security
Your widget runs in a sandboxed iframe with strict limitations:
What You CAN Do
โ
Read window.openai.* properties
โ
Render HTML, CSS, JavaScript (React, Vue, vanilla)
โ
Use inline styles or embedded CSS
โ
Use base64-encoded images or data URIs
โ
Make API calls to your own backend
โ
Use localStorage (scoped to your domain)
What You CANNOT Do
โ Access parent window (ChatGPTโs UI)
โ Load external scripts via <script src="...">
โ Load external stylesheets via <link rel="stylesheet">
โ Use plugins (Flash, Java applets)
โ Access the userโs camera/microphone without permission
โ Make cross-origin requests (unless your backend has CORS enabled)
Why these constraints?
Security. Your widget runs in the context of ChatGPT, so OpenAI restricts what it can do to protect users.
Solution: Bundle everything into a single, self-contained HTML file. This is why we use Vite with the vite-plugin-singlefile plugin.
How Widgets Connect to MCP Servers
Hereโs the complete data flow from MCP server to widget:
# In your MCP server (from Project 1)
@mcp.tool()
def get_weather(city: str, units: str = "celsius") -> dict:
"""Use this when user asks about weather conditions."""
# 1. Fetch data from weather API
weather_data = fetch_weather_api(city, units)
# 2. Return BOTH structured data AND widget URL
return {
"structuredContent": {
"city": city,
"temperature": weather_data.temp,
"condition": weather_data.condition,
"humidity": weather_data.humidity,
"wind": weather_data.wind
},
"uiComponent": {
"type": "iframe",
"url": "https://your-app.com/weather-widget.html"
}
}
// In your widget (React component)
import { useEffect, useState } from 'react';
export function WeatherWidget() {
const [weather, setWeather] = useState(null);
useEffect(() => {
// 3. Widget reads the data ChatGPT provides
const data = window.openai?.toolOutput;
if (data) {
setWeather(data);
}
}, []);
// 4. Render the data
if (!weather) return <div>Loading...</div>;
return (
<div>
<h2>Weather in {weather.city}</h2>
<p>Temperature: {weather.temperature}ยฐC</p>
<p>Condition: {weather.condition}</p>
</div>
);
}
Critical understanding:
- Your MCP server returns two things: data (
structuredContent) and a UI directive (uiComponent) - ChatGPT loads your widget URL and injects the
structuredContentaswindow.openai.toolOutput - Your widget is stateless on each renderโit always reads fresh data from
toolOutput
3. Solution Architecture
React Component Structure for Widgets
A well-structured widget separates concerns:
src/
โโโ components/
โ โโโ WeatherWidget.tsx # Main widget component
โ โโโ WeatherCard.tsx # Display component
โ โโโ WeatherActions.tsx # Button group component
โ โโโ LoadingState.tsx # Loading indicator
โโโ hooks/
โ โโโ useWeatherData.ts # Hook to read window.openai.toolOutput
โ โโโ useWeatherActions.ts # Hook for callTool/sendFollowUp
โโโ types/
โ โโโ weather.ts # TypeScript interfaces
โโโ utils/
โ โโโ formatting.ts # Format temperature, wind, etc.
โโโ main.tsx # Entry point
โโโ index.html # HTML template
Example component structure:
// src/components/WeatherWidget.tsx
import { WeatherCard } from './WeatherCard';
import { WeatherActions } from './WeatherActions';
import { useWeatherData } from '../hooks/useWeatherData';
export function WeatherWidget() {
const weather = useWeatherData();
if (!weather) {
return <LoadingState />;
}
return (
<div className="weather-widget">
<WeatherCard data={weather} />
<WeatherActions city={weather.city} />
</div>
);
}
// src/hooks/useWeatherData.ts
import { useEffect, useState } from 'react';
import type { WeatherData } from '../types/weather';
export function useWeatherData() {
const [weather, setWeather] = useState<WeatherData | null>(null);
useEffect(() => {
// Read data from ChatGPT
const data = window.openai?.toolOutput;
if (data) {
setWeather(data);
}
}, []);
return weather;
}
// src/components/WeatherActions.tsx
import { Button } from '@openai/apps-sdk-ui';
interface Props {
city: string;
}
export function WeatherActions({ city }: Props) {
const handleRefresh = () => {
window.openai?.callTool('get_weather', {
city,
units: 'celsius'
});
};
const handleForecast = () => {
window.openai?.sendFollowUpMessage(
`Show me the 5-day forecast for ${city}`
);
};
return (
<div className="actions">
<Button onClick={handleRefresh}>๐ Refresh</Button>
<Button onClick={handleForecast}>๐ 5-Day Forecast</Button>
</div>
);
}
Vite Configuration for Single-File Builds
ChatGPT widgets must be single HTML files with all CSS and JavaScript inlined. Vite + vite-plugin-singlefile handles this automatically.
// 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() // Bundles everything into one HTML file
],
build: {
outDir: 'dist',
rollupOptions: {
output: {
inlineDynamicImports: true, // Required for single file
},
},
},
});
What happens when you run npm run build:
- Vite compiles your React/TypeScript code to JavaScript
- CSS is extracted and embedded in
<style>tags - JavaScript is embedded in
<script>tags - Images (if any) are converted to base64 data URIs
- Output:
dist/index.html- a single, self-contained file
Example output:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Weather Widget</title>
<style>
/* All your CSS inlined here */
.weather-widget { padding: 1rem; }
/* ... */
</style>
</head>
<body>
<div id="root"></div>
<script type="module">
// All your JavaScript inlined here
import { jsx as _jsx } from 'react/jsx-runtime';
// ... entire React app bundled
</script>
</body>
</html>
Data Flow: MCP Tool โ toolOutput โ Widget Rendering
Letโs trace a complete request:
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ COMPLETE DATA FLOW โ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
1. USER INPUT
User types: "What's the weather in London?"
2. CHATGPT PROCESSING
ChatGPT analyzes the message and decides to call your tool:
Tool: get_weather
Parameters: { city: "London", units: "celsius" }
3. MCP SERVER EXECUTION
Your FastMCP server receives the request:
@mcp.tool()
def get_weather(city: str, units: str = "celsius"):
# Call weather API
data = requests.get(f"https://api.weather.com/v1/{city}")
# Return data + widget URL
return {
"structuredContent": {
"city": "London",
"temperature": 12,
"condition": "Rainy",
"humidity": 85,
"wind": "15 km/h NW"
},
"uiComponent": {
"type": "iframe",
"url": "https://your-app.com/weather.html"
}
}
4. CHATGPT RECEIVES RESPONSE
ChatGPT gets:
{
"structuredContent": { ... },
"uiComponent": { "type": "iframe", "url": "..." }
}
5. IFRAME CREATION
ChatGPT creates an iframe and loads your widget URL:
<iframe
src="https://your-app.com/weather.html"
sandbox="allow-scripts allow-same-origin"
></iframe>
6. WIDGET INITIALIZATION
Your widget HTML loads, React initializes, and runs:
useEffect(() => {
const data = window.openai?.toolOutput;
// data = {
// city: "London",
// temperature: 12,
// condition: "Rainy",
// humidity: 85,
// wind: "15 km/h NW"
// }
setWeather(data);
}, []);
7. RENDERING
React renders your component with the data:
<div>
<h2>Weather in London</h2>
<p>Temperature: 12ยฐC</p>
<p>Condition: Rainy</p>
<p>Humidity: 85%</p>
<p>Wind: 15 km/h NW</p>
</div>
8. USER INTERACTION
User clicks "Refresh" button in your widget:
window.openai?.callTool('get_weather', {
city: 'London',
units: 'celsius'
});
9. CYCLE REPEATS
Back to step 3 - MCP server is called again
Key insight: The widget never calls your backend directly. It always goes through window.openai.callTool(), which routes through ChatGPT, which calls your MCP server.
Handling User Interactions That Trigger Tools
There are two ways to trigger actions from your widget:
Option 1: Call a Tool Directly
const handleRefresh = () => {
window.openai?.callTool('get_weather', {
city: weather.city,
units: 'celsius'
});
};
Use when: You want to invoke a specific tool with exact parameters.
Result: ChatGPT calls your MCP tool, gets the response, and may re-render the widget with new data.
Option 2: Send a Follow-Up Message
const handleForecast = () => {
window.openai?.sendFollowUpMessage(
'Show me the 5-day forecast for London'
);
};
Use when: You want ChatGPT to interpret the request naturally (might call multiple tools, provide context, etc.).
Result: A new message appears in the chat as if the user typed it.
Advanced: Combining Both
const handleCompare = () => {
// First, call a tool
window.openai?.callTool('get_weather', {
city: 'Paris',
units: 'celsius'
});
// Then ask ChatGPT to compare
setTimeout(() => {
window.openai?.sendFollowUpMessage(
'Compare this with the weather in London'
);
}, 500);
};
4. Step-by-Step Implementation
Step 1: Project Setup
Create a new Vite + React + TypeScript project:
# Create project
npm create vite@latest weather-widget -- --template react-ts
# Navigate to project
cd weather-widget
# Install dependencies
npm install
# Install additional packages
npm install @openai/apps-sdk-ui
npm install -D vite-plugin-singlefile
Project structure after setup:
weather-widget/
โโโ node_modules/
โโโ public/
โโโ src/
โ โโโ App.tsx
โ โโโ main.tsx
โ โโโ vite-env.d.ts
โโโ index.html
โโโ package.json
โโโ tsconfig.json
โโโ vite.config.ts
Step 2: Configure Vite for Single-File Output
Edit 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()],
build: {
outDir: 'dist',
rollupOptions: {
output: {
inlineDynamicImports: true,
},
},
},
});
Step 3: Create TypeScript Type Definitions
Create src/types/weather.ts:
export interface WeatherData {
city: string;
temperature: number;
condition: string;
humidity: number;
wind: string;
units?: 'celsius' | 'fahrenheit';
}
export interface OpenAIWindow {
toolOutput?: WeatherData;
toolInput?: Record<string, any>;
callTool?: (name: string, params: Record<string, any>) => void;
sendFollowUpMessage?: (message: string) => void;
widgetState?: any;
setWidgetState?: (state: any) => void;
requestFullscreen?: () => void;
exitFullscreen?: () => void;
}
declare global {
interface Window {
openai?: OpenAIWindow;
}
}
Step 4: Create the Weather Widget Component
Create src/components/WeatherWidget.tsx:
import { useEffect, useState } from 'react';
import { Button } from '@openai/apps-sdk-ui';
import type { WeatherData } from '../types/weather';
import './WeatherWidget.css';
export function WeatherWidget() {
const [weather, setWeather] = useState<WeatherData | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
// Read data from ChatGPT
const data = window.openai?.toolOutput as WeatherData | undefined;
if (data) {
setWeather(data);
setLoading(false);
} else {
console.warn('No weather data available in toolOutput');
setLoading(false);
}
}, []);
const handleRefresh = () => {
if (!weather?.city) return;
window.openai?.callTool('get_weather', {
city: weather.city,
units: weather.units || 'celsius'
});
};
const handleForecast = () => {
if (!weather?.city) return;
window.openai?.sendFollowUpMessage(
`Show me the 5-day forecast for ${weather.city}`
);
};
const handleChangeCity = () => {
window.openai?.sendFollowUpMessage(
'What city would you like to check the weather for?'
);
};
if (loading) {
return (
<div className="weather-widget loading">
<div className="spinner"></div>
<p>Loading weather data...</p>
</div>
);
}
if (!weather) {
return (
<div className="weather-widget error">
<p>โ No weather data available</p>
</div>
);
}
return (
<div className="weather-widget">
<div className="weather-header">
<h2>๐ค๏ธ Weather in {weather.city}</h2>
</div>
<div className="weather-content">
<div className="weather-main">
<div className="temperature">
{weather.temperature}ยฐ{weather.units === 'fahrenheit' ? 'F' : 'C'}
</div>
<div className="condition">{weather.condition}</div>
</div>
<div className="weather-details">
<div className="detail-item">
<span className="label">Humidity</span>
<span className="value">{weather.humidity}%</span>
</div>
<div className="detail-item">
<span className="label">Wind</span>
<span className="value">{weather.wind}</span>
</div>
</div>
</div>
<div className="weather-actions">
<Button onClick={handleRefresh} variant="secondary">
๐ Refresh
</Button>
<Button onClick={handleChangeCity} variant="secondary">
๐ Change City
</Button>
<Button onClick={handleForecast} variant="primary">
๐ 5-Day Forecast
</Button>
</div>
</div>
);
}
Create src/components/WeatherWidget.css:
.weather-widget {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', sans-serif;
padding: 1.5rem;
max-width: 100%;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
border-radius: 12px;
color: white;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
}
.weather-header h2 {
margin: 0 0 1rem 0;
font-size: 1.5rem;
font-weight: 600;
}
.weather-content {
background: rgba(255, 255, 255, 0.1);
border-radius: 8px;
padding: 1.5rem;
margin-bottom: 1rem;
}
.weather-main {
text-align: center;
margin-bottom: 1.5rem;
}
.temperature {
font-size: 3rem;
font-weight: 700;
line-height: 1;
}
.condition {
font-size: 1.25rem;
margin-top: 0.5rem;
opacity: 0.9;
}
.weather-details {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1rem;
}
.detail-item {
display: flex;
flex-direction: column;
align-items: center;
padding: 0.75rem;
background: rgba(255, 255, 255, 0.1);
border-radius: 6px;
}
.detail-item .label {
font-size: 0.875rem;
opacity: 0.8;
margin-bottom: 0.25rem;
}
.detail-item .value {
font-size: 1.125rem;
font-weight: 600;
}
.weather-actions {
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
}
.weather-actions button {
flex: 1;
min-width: 100px;
}
.weather-widget.loading,
.weather-widget.error {
text-align: center;
padding: 2rem;
}
.spinner {
width: 40px;
height: 40px;
margin: 0 auto 1rem;
border: 4px solid rgba(255, 255, 255, 0.3);
border-top-color: white;
border-radius: 50%;
animation: spin 1s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
Step 5: Update Main Entry Point
Edit src/main.tsx:
import React from 'react';
import ReactDOM from 'react-dom/client';
import { WeatherWidget } from './components/WeatherWidget';
import './index.css';
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<WeatherWidget />
</React.StrictMode>,
);
Create src/index.css:
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
#root {
width: 100%;
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
background: #f5f5f5;
}
Step 6: Update MCP Server to Return Widget
Modify your MCP server from Project 1 to include the widget URL:
# In your MCP server (e.g., main.py)
from fastmcp import FastMCP
import requests
from datetime import datetime
mcp = FastMCP("Weather App")
@mcp.tool()
def get_weather(city: str, units: str = "celsius") -> dict:
"""Use this when user asks about weather conditions for a city."""
# Call a real weather API (example using OpenWeatherMap)
# You'll need to sign up for a free API key at openweathermap.org
API_KEY = "your_api_key_here"
url = f"http://api.openweathermap.org/data/2.5/weather?q={city}&appid={API_KEY}&units=metric"
try:
response = requests.get(url)
response.raise_for_status()
data = response.json()
weather_data = {
"city": city,
"temperature": round(data["main"]["temp"]),
"condition": data["weather"][0]["description"].title(),
"humidity": data["main"]["humidity"],
"wind": f"{data['wind']['speed']} km/h",
"units": units
}
return {
"structuredContent": weather_data,
"uiComponent": {
"type": "iframe",
"url": "http://localhost:5173" # Development URL
# In production: "https://your-app.com/weather-widget.html"
}
}
except Exception as e:
return {
"structuredContent": {
"error": f"Failed to fetch weather: {str(e)}"
}
}
Step 7: Build and Deploy the Widget
Development:
# Run the widget in development mode
npm run dev
# This starts a local server at http://localhost:5173
# Your MCP server can point to this URL during development
Production:
# Build the widget
npm run build
# This creates dist/index.html - a single, self-contained file
# Deploy to hosting (examples):
# Option 1: Vercel
npx vercel --prod
# Option 2: Netlify
npx netlify deploy --prod --dir=dist
# Option 3: AWS S3
aws s3 cp dist/index.html s3://your-bucket/weather-widget.html --acl public-read
# Option 4: GitHub Pages
# Push dist/index.html to gh-pages branch
Update MCP server with production URL:
"uiComponent": {
"type": "iframe",
"url": "https://your-app.vercel.app" # Or your hosting URL
}
5. Testing and Validation
Local Testing Strategy
Test 1: Widget Renders Standalone
npm run dev
Open http://localhost:5173 in your browser. You should see the widget, but without data (since window.openai doesnโt exist outside ChatGPT).
Mock the data for local testing:
// In WeatherWidget.tsx, add mock data for development
useEffect(() => {
const data = window.openai?.toolOutput as WeatherData | undefined;
if (data) {
setWeather(data);
} else {
// Mock data for local development
setWeather({
city: 'Tokyo',
temperature: 15,
condition: 'Partly Cloudy',
humidity: 72,
wind: '8 km/h E',
units: 'celsius'
});
}
setLoading(false);
}, []);
Test 2: Build Process
npm run build
Check that dist/index.html exists and is a single file with all assets inlined.
Test 3: MCP Server Integration
Start your MCP server:
uvicorn main:mcp.app --port 8000
Test with MCP Inspector:
npx @anthropic/mcp-inspector
Invoke get_weather and verify:
structuredContentcontains weather datauiComponent.urlpoints to your widget
Test 4: End-to-End in ChatGPT
Once deployed, test in ChatGPT:
- Install your app in ChatGPT
- Ask: โWhatโs the weather in London?โ
- Verify widget loads with correct data
- Click โRefreshโ - widget should update
- Click โ5-Day Forecastโ - new message appears
Common Issues and Debugging
Issue 1: Widget Shows โLoadingโฆโ Forever
Cause: window.openai.toolOutput is undefined
Debug:
useEffect(() => {
console.log('window.openai:', window.openai);
console.log('toolOutput:', window.openai?.toolOutput);
}, []);
Fix: Ensure your MCP server returns structuredContent
Issue 2: Styles Donโt Appear
Cause: External CSS not inlined
Fix: Ensure vite-plugin-singlefile is installed and configured
Issue 3: โcallTool is not a functionโ
Cause: Widget loaded outside ChatGPT context
Fix: Only test tool calls inside ChatGPT, not standalone
Issue 4: CORS Errors
Cause: Widget making API calls to your backend
Fix: Enable CORS on your backend:
from fastapi.middleware.cors import CORSMiddleware
app.add_middleware(
CORSMiddleware,
allow_origins=["https://chatgpt.com"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
6. Advanced Features
Feature 1: Persisting User Preferences
function WeatherWidget() {
const [weather, setWeather] = useState<WeatherData | null>(null);
const [units, setUnits] = useState<'celsius' | 'fahrenheit'>('celsius');
useEffect(() => {
// Load saved preferences
const savedState = window.openai?.widgetState;
if (savedState?.units) {
setUnits(savedState.units);
}
const data = window.openai?.toolOutput as WeatherData | undefined;
if (data) {
setWeather(data);
}
}, []);
const toggleUnits = () => {
const newUnits = units === 'celsius' ? 'fahrenheit' : 'celsius';
setUnits(newUnits);
// Save preference
window.openai?.setWidgetState({ units: newUnits });
// Refresh with new units
if (weather?.city) {
window.openai?.callTool('get_weather', {
city: weather.city,
units: newUnits
});
}
};
return (
<div>
{/* ... */}
<Button onClick={toggleUnits}>
Switch to ยฐ{units === 'celsius' ? 'F' : 'C'}
</Button>
</div>
);
}
Feature 2: Multiple Cities Comparison
function WeatherComparison() {
const [cities, setCities] = useState<WeatherData[]>([]);
useEffect(() => {
const data = window.openai?.toolOutput as { cities: WeatherData[] };
if (data?.cities) {
setCities(data.cities);
}
}, []);
return (
<div className="comparison-grid">
{cities.map(city => (
<WeatherCard key={city.city} data={city} />
))}
</div>
);
}
Corresponding MCP tool:
@mcp.tool()
def compare_weather(cities: list[str], units: str = "celsius") -> dict:
"""Use this when user wants to compare weather across multiple cities."""
weather_data = [get_weather_for_city(city, units) for city in cities]
return {
"structuredContent": {
"cities": weather_data
},
"uiComponent": {
"type": "iframe",
"url": "https://your-app.com/weather-comparison.html"
}
}
Feature 3: Weather Alerts
function WeatherAlerts() {
const [alerts, setAlerts] = useState<Alert[]>([]);
useEffect(() => {
const data = window.openai?.toolOutput as { alerts: Alert[] };
if (data?.alerts) {
setAlerts(data.alerts);
}
}, []);
if (alerts.length === 0) return null;
return (
<div className="alerts">
{alerts.map(alert => (
<div key={alert.id} className={`alert alert-${alert.severity}`}>
<strong>{alert.title}</strong>
<p>{alert.description}</p>
</div>
))}
</div>
);
}
7. Optimization and Best Practices
Performance Optimization
1. Minimize Bundle Size
// Instead of importing entire libraries
import { Button } from '@openai/apps-sdk-ui'; // โ Imports everything
// Import only what you need
import Button from '@openai/apps-sdk-ui/Button'; // โ
Smaller bundle
2. Lazy Load Components
import { lazy, Suspense } from 'react';
const WeatherChart = lazy(() => import('./WeatherChart'));
function WeatherWidget() {
return (
<Suspense fallback={<div>Loading chart...</div>}>
<WeatherChart data={weather} />
</Suspense>
);
}
3. Optimize Images
// Convert images to base64 and inline them
const weatherIcon = "data:image/svg+xml;base64,PHN2ZyB4bWxucz0i...";
<img src={weatherIcon} alt="Weather icon" />
Security Best Practices
1. Sanitize User Input
import DOMPurify from 'dompurify';
function WeatherWidget() {
const data = window.openai?.toolOutput;
// Sanitize before rendering
const safeCity = DOMPurify.sanitize(data.city);
return <h2>Weather in {safeCity}</h2>;
}
2. Validate Data Structure
function isValidWeatherData(data: any): data is WeatherData {
return (
typeof data === 'object' &&
typeof data.city === 'string' &&
typeof data.temperature === 'number' &&
typeof data.condition === 'string'
);
}
useEffect(() => {
const data = window.openai?.toolOutput;
if (isValidWeatherData(data)) {
setWeather(data);
} else {
console.error('Invalid weather data:', data);
}
}, []);
Accessibility
1. Semantic HTML
<article className="weather-widget" role="region" aria-label="Weather information">
<header>
<h2>Weather in {weather.city}</h2>
</header>
<section aria-label="Current conditions">
<div role="text" aria-label={`Temperature: ${weather.temperature} degrees`}>
{weather.temperature}ยฐC
</div>
</section>
</article>
2. Keyboard Navigation
function WeatherActions() {
return (
<div role="group" aria-label="Weather actions">
<Button
onClick={handleRefresh}
aria-label="Refresh weather data"
tabIndex={0}
>
๐ Refresh
</Button>
</div>
);
}
3. Loading States
{loading && (
<div role="status" aria-live="polite">
<span className="sr-only">Loading weather data...</span>
<div className="spinner" aria-hidden="true"></div>
</div>
)}
8. Troubleshooting Guide
Problem 1: Widget Not Loading in ChatGPT
Symptoms: ChatGPT shows a blank iframe or error
Possible causes:
- Widget URL is incorrect
- CORS issues
- Widget has JavaScript errors
Solutions:
# 1. Verify URL is accessible
curl https://your-app.com/weather-widget.html
# 2. Check browser console in ChatGPT
# Right-click iframe โ Inspect โ Console tab
# 3. Test widget standalone
open https://your-app.com/weather-widget.html
Problem 2: Data Not Appearing in Widget
Symptoms: Widget renders but shows โLoadingโฆโ or no data
Debug:
useEffect(() => {
console.log('=== DEBUG INFO ===');
console.log('window.openai exists:', !!window.openai);
console.log('toolOutput:', window.openai?.toolOutput);
console.log('toolInput:', window.openai?.toolInput);
console.log('==================');
}, []);
Check MCP server response:
@mcp.tool()
def get_weather(city: str) -> dict:
data = fetch_weather(city)
# Log what you're returning
print("Returning:", {
"structuredContent": data,
"uiComponent": {"type": "iframe", "url": "..."}
})
return {
"structuredContent": data,
"uiComponent": {"type": "iframe", "url": "..."}
}
Problem 3: Buttons Donโt Work
Symptoms: Clicking buttons does nothing
Possible causes:
window.openai.callToolis undefined- Tool name doesnโt match MCP server
- Parameters are incorrect
Solutions:
const handleRefresh = () => {
// Check if API exists
if (!window.openai?.callTool) {
console.error('window.openai.callTool is not available');
return;
}
console.log('Calling tool: get_weather', {
city: weather.city,
units: 'celsius'
});
try {
window.openai.callTool('get_weather', {
city: weather.city,
units: 'celsius'
});
} catch (error) {
console.error('Error calling tool:', error);
}
};
Problem 4: Build Fails
Symptoms: npm run build throws errors
Common errors:
# Error: Module not found
npm install --save-dev vite-plugin-singlefile
# Error: TypeScript errors
npm run build -- --mode development # Skip type checking
# Error: Out of memory
export NODE_OPTIONS="--max-old-space-size=4096"
npm run build
9. Real-World Applications
Use Case 1: Financial Dashboard Widget
Display stock prices, portfolio performance, and market news.
MCP Tool:
@mcp.tool()
def get_portfolio(user_id: str) -> dict:
portfolio = fetch_user_portfolio(user_id)
return {
"structuredContent": {
"totalValue": portfolio.total_value,
"dayChange": portfolio.day_change,
"stocks": portfolio.stocks
},
"uiComponent": {
"type": "iframe",
"url": "https://your-app.com/portfolio-widget.html"
}
}
Use Case 2: Task Management Widget
Create, view, and complete tasks directly in ChatGPT.
Widget Features:
- Display task list with checkboxes
- Add new tasks via form
- Mark tasks complete via
callTool
Use Case 3: Restaurant Finder Widget
Show nearby restaurants on a map with ratings and photos.
Integration:
- Google Places API for restaurant data
- Mapbox for interactive map
- User can click โGet Directionsโ to trigger navigation
10. Integration with Other Systems
Integrating with Your Backend API
function WeatherWidget() {
const [weather, setWeather] = useState(null);
const [extended, setExtended] = useState(null);
useEffect(() => {
const data = window.openai?.toolOutput;
setWeather(data);
// Optionally fetch additional data from your backend
if (data?.city) {
fetch(`https://your-backend.com/api/weather/extended?city=${data.city}`)
.then(res => res.json())
.then(setExtended);
}
}, []);
return (
<div>
<WeatherCard data={weather} />
{extended && <ExtendedForecast data={extended} />}
</div>
);
}
Using Third-Party Libraries
Chart.js for visualizations:
import { Line } from 'react-chartjs-2';
function TemperatureChart({ forecast }: { forecast: DailyForecast[] }) {
const data = {
labels: forecast.map(d => d.day),
datasets: [{
label: 'Temperature',
data: forecast.map(d => d.temperature),
borderColor: 'rgb(75, 192, 192)',
}]
};
return <Line data={data} />;
}
Important: Ensure libraries are compatible with iframe rendering and donโt require external resources.
11. Learning Exercises
Exercise 1: Add Unit Conversion
Goal: Allow users to toggle between Celsius and Fahrenheit
Tasks:
- Add a button to toggle units
- Save preference using
widgetState - Call
get_weatherwith new units parameter
Starter code:
const [units, setUnits] = useState<'celsius' | 'fahrenheit'>('celsius');
const toggleUnits = () => {
// TODO: Implement unit toggle
};
Exercise 2: Add Hourly Forecast
Goal: Show hourly temperature and conditions
Tasks:
- Create new MCP tool
get_hourly_forecast - Create
HourlyForecastcomponent - Add button to switch between current and hourly view
Exercise 3: Add Location Search
Goal: Let users search for cities without asking ChatGPT
Tasks:
- Add search input field
- Call
callToolon search submit - Display search suggestions
Challenge: Implement autocomplete using a geocoding API
12. Next Steps
What Youโve Learned
โ
How web components work in ChatGPTโs iframe environment
โ
How to use the window.openai API to read data and trigger actions
โ
How to configure Vite for single-file widget builds
โ
How to connect a widget to an MCP server
โ
How to handle user interactions in widgets
Recommended Follow-Up Projects
- Project 3: Interactive List & Search App
- Learn state management with
widgetState - Implement pagination and filtering
- Build a product catalog
- Learn state management with
- Project 4: Map & Location-Based App
- Integrate Mapbox or Google Maps
- Show markers and interactive overlays
- Build a store locator
- Project 5: Form-Based Data Entry App
- Create multi-step forms
- Implement validation
- Handle destructive actions
Resources for Deeper Learning
Official Documentation:
Community Resources:
Books:
- โLearning Reactโ by Eve Porcello & Alex Banks
- โTypeScript Quicklyโ by Yakov Fain & Anton Moiseev
- โDesigning Interfacesโ by Jenifer Tidwell
13. Conclusion and Reflection
Key Takeaways
The Widget Pattern: Widgets are reactive components that read data from toolOutput and trigger actions via callTool(). They donโt fetch data directlyโthey communicate through ChatGPT.
The Data Flow: User โ ChatGPT โ MCP Server โ structuredContent โ window.openai.toolOutput โ Widget
The Build Process: Use Vite with vite-plugin-singlefile to create self-contained HTML files that can be deployed anywhere.
The Communication API: window.openai is your bridge to ChatGPT. Master toolOutput, callTool(), sendFollowUpMessage(), and widgetState.
Common Pitfalls to Avoid
โ Donโt make API calls directly from widgetsโuse MCP tools
โ Donโt forget to handle loading and error states
โ Donโt assume window.openai always existsโcheck for undefined
โ Donโt load external resourcesโbundle everything into one file
What Makes This Project Foundational
Every ChatGPT app you build will use these concepts:
- Reading
toolOutput - Rendering React components
- Calling tools on user interaction
- Managing state across conversation turns
Master this project, and youโve unlocked the foundation of the entire platform.
Celebrating Your Progress
Youโve built your first ChatGPT widget! You can now:
โ Create custom UIs that render in ChatGPT โ Display data from your MCP server โ Handle user interactions โ Build and deploy widgets
Next challenge: Take what youโve learned and build a widget for your own use caseโweather, stocks, tasks, recipes, anything youโre interested in. The patterns are the same; only the data changes.
Welcome to the world of ChatGPT app development!