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.openai API
  • 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:

  1. How widgets are built and bundled using React and Vite
  2. How the window.openai API works for reading data and triggering actions
  3. How widgets render in ChatGPTโ€™s iframe with sandbox constraints
  4. 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:

  1. Runs in a sandboxed environment (iframe with limited permissions)
  2. Receives data from your MCP server via the window.openai.toolOutput property
  3. Can communicate back to ChatGPT through the window.openai API
  4. 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 structuredContent as window.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:

  1. Vite compiles your React/TypeScript code to JavaScript
  2. CSS is extracted and embedded in <style> tags
  3. JavaScript is embedded in <script> tags
  4. Images (if any) are converted to base64 data URIs
  5. 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:

  • structuredContent contains weather data
  • uiComponent.url points to your widget

Test 4: End-to-End in ChatGPT

Once deployed, test in ChatGPT:

  1. Install your app in ChatGPT
  2. Ask: โ€œWhatโ€™s the weather in London?โ€
  3. Verify widget loads with correct data
  4. Click โ€œRefreshโ€ - widget should update
  5. 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:

  1. Widget URL is incorrect
  2. CORS issues
  3. 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:

  1. window.openai.callTool is undefined
  2. Tool name doesnโ€™t match MCP server
  3. 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:

  1. Add a button to toggle units
  2. Save preference using widgetState
  3. Call get_weather with 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:

  1. Create new MCP tool get_hourly_forecast
  2. Create HourlyForecast component
  3. Add button to switch between current and hourly view

Goal: Let users search for cities without asking ChatGPT

Tasks:

  1. Add search input field
  2. Call callTool on search submit
  3. 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

  1. Project 3: Interactive List & Search App
    • Learn state management with widgetState
    • Implement pagination and filtering
    • Build a product catalog
  2. Project 4: Map & Location-Based App
    • Integrate Mapbox or Google Maps
    • Show markers and interactive overlays
    • Build a store locator
  3. 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!