A template for building ChatGPT Apps (widgets) using the ChatGPT Apps SDK, served via a Cloudflare Workers MCP server.
This project provides an extensible system for registering and serving widgets in ChatGPT. Widgets are React components that are built and bundled, then served by the MCP server with all necessary assets (JavaScript, CSS, and global styles) embedded inline.
├── web/ # Widget development
│ ├── widgets/
│ │ └── reservation-card.tsx # Example widget component
│ ├── dist/ # Built assets
│ │ ├── globals.css # Shared Tailwind styles (auto-embedded)
│ │ ├── reservation-card.css # Widget-specific styles
│ │ └── reservation-card.js # Widget bundle
│ ├── globals.css # Tailwind source
│ └── package.json # Build configuration
│
├── server/ # MCP server
│ ├── widgets/
│ │ ├── config.ts # Widget registry (ADD NEW WIDGETS HERE)
│ │ ├── registry.ts # Registration logic
│ │ └── utils.ts # Asset loading utilities
│ └── index.ts # Server entry point, manual tool registration
│
└── package.json # Root workspace
Create a new React component in web/widgets/:
// web/widgets/flight-status.tsx
import { createRoot } from 'react-dom/client';
import { Badge } from '@openai/apps-sdk-ui/components/Badge';
import { Button } from '@openai/apps-sdk-ui/components/Button';
export function FlightStatus() {
return (
<div className="w-full max-w-sm rounded-2xl border border-default bg-surface shadow-lg p-4">
<h2 className="heading-lg">Flight AA1234</h2>
<Badge color="success">On Time</Badge>
{/* Your widget UI here */}
</div>
);
}
// Mount the component
createRoot(document.getElementById('flight-root')!).render(<FlightStatus />);Key conventions:
- Export a named React component
- Use OpenAI Apps SDK UI components for consistency
- Use Tailwind CSS classes for styling
- Mount to a DOM element with a unique ID (e.g.,
flight-root)
Update web/package.json to build your new widget:
{
"scripts": {
"build": "npm run build:reservation-card && npm run build:flight-status && npm run build:css",
"build:reservation-card": "esbuild widgets/reservation-card.tsx --bundle --format=esm --outfile=dist/reservation-card.js",
"build:flight-status": "esbuild widgets/flight-status.tsx --bundle --format=esm --outfile=dist/flight-status.js",
"build:css": "npx @tailwind/cli -i globals.css -o dist/globals.css"
}
}Add your widget configuration to server/widgets/config.ts:
export const WIDGET_CONFIGS: WidgetConfig[] = [
{
name: 'reservation-card',
uri: 'ui://widget/reservation-card.html',
title: 'Reservation Card Widget',
description: 'Reservation card UI widget for ChatGPT',
rootElementId: 'reservation-root',
meta: {
prefersBorder: true,
domain: 'https://chatgpt.com',
csp: {
connect_domains: ['https://chatgpt.com'],
resource_domains: ['https://*.oaistatic.com'],
},
},
},
// Add your new widget here:
{
name: 'flight-status', // Must match filename (flight-status.tsx -> flight-status)
uri: 'ui://widget/flight-status.html', // Unique widget URI
title: 'Flight Status Widget', // Display title
description: 'Real-time flight status widget',
rootElementId: 'flight-root', // Must match the ID in createRoot()
meta: {
prefersBorder: true, // Optional: add border in ChatGPT UI
domain: 'https://chatgpt.com', // Allowed domain
csp: { // Content Security Policy
connect_domains: ['https://chatgpt.com', 'https://api.example.com'],
resource_domains: ['https://*.oaistatic.com'],
},
},
},
];That's it! Your widget resource is now automatically registered. The system will:
- ✅ Load
globals.css(shared Tailwind styles) - ✅ Load
flight-status.css(widget-specific styles) - ✅ Load
flight-status.js(widget bundle) - ✅ Embed all three inline in the HTML response
- ✅ Register the resource with the MCP server
If your widget needs to be invoked via a tool, manually register it in server/index.ts:
server.registerTool(
'flight-status',
{
title: 'Show Flight Status',
inputSchema: { flightNumber: z.string() },
_meta: {
'openai/outputTemplate': 'ui://widget/flight-status.html', // Must match widget URI
'openai/toolInvocation/invoking': 'Checking flight status...',
'openai/toolInvocation/invoked': 'Flight status ready.',
},
},
async ({ flightNumber }) => {
return {
content: [
{
type: 'text',
text: `Showing flight status for ${flightNumber}`,
},
],
};
},
);# Build widgets
npm run build
# Test locally
npm run dev
# Deploy to Cloudflare
npm run deployinterface WidgetConfig {
name: string; // Widget identifier, must match filename without extension
uri: string; // Widget URI in format 'ui://widget/{name}.html'
title: string; // Display title for the widget resource
description: string; // Description of the widget
rootElementId: string; // DOM element ID where React component mounts
meta?: {
prefersBorder?: boolean; // Whether to show border in ChatGPT UI
domain?: string; // Allowed domain (usually 'https://chatgpt.com')
csp?: {
connect_domains?: string[]; // Domains widget can connect to (APIs, etc.)
resource_domains?: string[]; // Domains for loading resources
};
};
}- Module Initialization: When the server starts, widgets are registered from
WIDGET_CONFIGS - Lazy Asset Loading: Widget assets (CSS/JS) are loaded only when the resource is requested
- Inline Embedding: All assets are embedded inline in the HTML response
- Globals CSS: Every widget automatically includes
dist/globals.cssfor shared Tailwind styles
ChatGPT requests widget resource
↓
server/widgets/registry.ts
↓
Loads 3 files in parallel:
- dist/globals.css (shared)
- dist/{widget-name}.css (widget-specific)
- dist/{widget-name}.js (widget bundle)
↓
Generates HTML with inline <style> and <script>
↓
Returns to ChatGPT
- Widget naming: Use kebab-case for consistency (e.g.,
flight-status, notFlightStatus) - Root element ID: Convention is
{widget-name}-root(e.g.,flight-root) - Tailwind classes: Use OpenAI Apps SDK design tokens (
border-default,bg-surface, etc.) - Props: Currently, widgets don't receive props - data is hard-coded (consider extending for dynamic data)
- Build output: Check
web/dist/to verify your widget built correctly
To pass data from tool invocation to widgets, you'll need to:
- Modify the widget component to accept props or read from a data attribute
- Update the HTML generation in
server/widgets/utils.tsto inject data - Pass data from the tool handler to the widget resource
Add custom metadata fields to WidgetConfig.meta as needed for your use case.
Consider adding validation in server/widgets/registry.ts to ensure:
- Required asset files exist
- Configuration is valid
- URIs follow expected patterns
Widget not showing up:
- Verify widget is in
WIDGET_CONFIGSarray - Check that
namematches the filename (without.tsx) - Ensure
rootElementIdmatches the ID increateRoot()
Styles not working:
- Run
npm run buildto rebuild CSS - Verify
globals.cssis being embedded (check network tab) - Check Tailwind class names match OpenAI SDK tokens
TypeScript errors:
- Run
npm run cf-typegento regenerate Cloudflare types - Check imports are correct in widget files