This package provides a Vite plugin and runtime helpers for building and serving React widget components that can be embedded in ChatGPT's UI through the ChatGPT Apps SDK, which uses MCP (Model Context Protocol) resources. The widgets run in sandboxed iframes within ChatGPT's interface.
ChatGPT needs to display custom UI widgets from external applications, but faces these constraints:
- Sandboxed iframes: Widgets load in iframes with restricted permissions
- Cross-origin assets: The iframe domain differs from the asset host domain
- Dynamic vs static content: Dev mode needs live reloading, production needs built artifacts
- Multiple entrypoints: Each widget is a separate entrypoint that must be built independently
This plugin solves these problems by:
- Automatically creating virtual HTML entrypoints for each widget component
- Transforming all asset URLs to be fully qualified absolute URLs
- Providing helpers that work seamlessly in both dev and production modes
- Managing the build configuration to bundle widgets as separate chunks
1. Vite Plugin (chatGPTWidgetPlugin)
- Scans a directory for React component files (
.tsx,.ts,.jsx,.js) - Creates virtual modules for each widget:
virtual:chatgpt-widget-{name}.html- HTML entrypointvirtual:chatgpt-widget-{name}.js- JavaScript entrypoint that renders the React component
- Adds these as Rollup inputs during build
- Generates standalone HTML files with hashed assets
2. Runtime Helpers (getWidgets, getWidgetHTML)
- Retrieve widget HTML content for serving via MCP
- In development: Uses Vite's
pluginContainerandtransformIndexHtmlto generate HTML on-the-fly - In production: Reads pre-built HTML files from disk using Vite's manifest.json
- Transforms URLs to be fully qualified (absolute with protocol and domain)
your-project/
├── web/chatgpt/ # Widget components directory (configurable)
│ ├── root.tsx # Optional: Root layout wrapper for all widgets
│ ├── MyWidget.tsx # Individual widget components
│ └── AnotherWidget.tsx
├── dist/ # Production build output
│ ├── .vite/
│ │ └── manifest.json # Vite manifest (required for production)
│ ├── virtual:chatgpt-widget-MyWidget.html # Built HTML files
│ └── assets/
│ └── chatgpt-widget-MyWidget-{hash}.js # Bundled JS with content hash
└── vite.config.ts # Vite configuration
The plugin creates virtual modules - files that don't exist on disk but are resolved by the plugin:
-
Virtual HTML files (
virtual:chatgpt-widget-{name}.html):- Resolved and loaded by the plugin's
resolveIdandloadhooks - Generate a minimal HTML document with a root div and script tag
- Example:
virtual:chatgpt-widget-Hello.html
- Resolved and loaded by the plugin's
-
Virtual JS files (
\0virtual:chatgpt-widget-{name}.js):- Prefixed with
\0to mark as virtual (Rollup convention) - Import the actual widget component and render it with React
- Handle optional root layout wrapping
- Example:
\0virtual:chatgpt-widget-Hello.js
- Prefixed with
If a file named root.tsx (or .ts, .jsx, .js) exists in the widgets directory:
- It's not treated as a widget itself
- It's automatically used to wrap all other widgets
- Must accept a
childrenprop - Use case: Common providers, styles, headers/footers
Critical constraint: ChatGPT's sandboxed iframes require all asset URLs to be absolute.
The plugin enforces this by:
- Requiring either
vite.config.baseorplugin.baseUrlto be an absolute URL - Transforming all
/path/to/asset.jsURLs tohttps://example.com/path/to/asset.js - Throwing clear errors if absolute URLs are not configured
URL transformation happens in transformHtmlWithAbsoluteUrls():
- Rewrites
<script src="/..."> - Rewrites
<link href="/..."> - Preserves already-absolute URLs
- Normalizes base URL to ensure trailing slash
Development Mode (getWidgets with { devServer: ViteDevServer }):
- Calls
vite.pluginContainer.resolveId()to resolve virtual module - Calls
vite.pluginContainer.load()to get raw HTML from plugin - Calls
vite.transformIndexHtml()to process through Vite's HTML pipeline - Rewrites
src="virtual:..."tosrc="/@id/virtual:..."(dev server convention) - Applies
transformHtmlWithAbsoluteUrls()to make all URLs absolute
Production Mode (getWidgets with { manifestPath: "..." }):
- Reads Vite's
manifest.jsonto find built HTML file paths - Reads the pre-built HTML file from disk
- No transformation needed - URLs were made absolute during build
// In your MCP server setup
import { getWidgets } from "vite-plugin-chatgpt-widgets";
// Determine mode and get widgets
const widgets =
process.env.NODE_ENV === "production"
? await getWidgets("web/chatgpt", { manifestPath: "dist/.vite/manifest.json" })
: await getWidgets("web/chatgpt", { devServer: viteDevServerInstance });
// Register each as an MCP resource
for (const widget of widgets) {
mcpServer.registerResource(
`widget-${widget.name}`,
`ui://widget/${widget.name}.html`,
{
/* metadata */
},
async () => ({
contents: [
{
uri: `ui://widget/${widget.name}.html`,
mimeType: "text/html+skybridge",
text: widget.content,
},
],
})
);
}// web/chatgpt/DataDisplay.tsx
export default function DataDisplay() {
// Access tool output from ChatGPT
const data = window.openai?.tool_output;
return <div>{/* Render your UI */}</div>;
}// web/chatgpt/root.tsx
import { ThemeProvider } from "./theme";
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<ThemeProvider>
<div className="widget-container">{children}</div>
</ThemeProvider>
);
}- Absolute base URL: Either
vite.config.baseorplugin.baseUrlmust be an absolute URL with protocol - Build manifest enabled: In production, must have
build.manifest: truein Vite config - CORS configured: Dev server needs CORS enabled for cross-origin iframe requests
- Widgets directory exists: Directory must exist and contain component files
- HTML is served via MCP: Widget HTML must be exposed as MCP resources
- Assets are accessible: The asset URLs must be reachable from user's browser
- MIME type correct: Use
text/html+skybridgefor MCP resource content - React is available: Widget JS expects React and ReactDOM to be bundled
The package includes test fixtures in spec/fixtures/ that demonstrate:
- Basic widget setup (
test-project) - Widgets with root layout (
test-project-with-root) - React Router integration (
test-project-react-router) - Plain React with @vitejs/plugin-react (
test-project-plain-react)
When testing:
- Build the fixture project
- Verify manifest.json contains widget entries
- Verify HTML files exist in dist
- Call
getWidgets()and check returned HTML content - Verify all URLs in HTML are absolute
src/index.ts: Single source file containing all plugin and helper logicspec/integration.spec.ts: Integration tests that build fixtures and verify outputspec/chatgpt-widgets.spec.ts: Unit tests for plugin functionalityspec/fixtures/*/vite.config.ts: Example Vite configurations using the plugindist/cjs/anddist/esm/: Dual CJS/ESM builds for compatibility
- Plugin is source of truth: The Vite plugin generates the canonical HTML structure
- Helpers use plugin output: Runtime helpers never recreate HTML - they retrieve it from the plugin
- Dev mode uses Vite's pipeline: Leverage Vite's existing HTML transformation for consistency
- Production reads artifacts: No dynamic generation in prod - just read what was built
- Fail fast on misconfiguration: Throw clear errors at config time, not runtime
When modifying this package:
- Maintain the dual mode design: Any changes must work in both dev and production
- Keep URL transformation consistent: Both modes must produce identical URL structures
- Test with actual ChatGPT integration: The sandboxed iframe has unique constraints
- Preserve the virtual module pattern: This is core to how widgets are bundled separately
- Document baseUrl requirements clearly: This is the #1 confusion point for users
- Consider React Router and SPA frameworks: Test fixtures show these integration patterns
When debugging issues:
- Check which mode (dev/prod) is being used
- Verify baseUrl/base configuration is absolute
- Inspect the generated HTML to see actual URLs
- Check if CORS is enabled for dev server
- Verify manifest.json contains expected entries
- Look at Vite's resolved config to see effective settings
- Null-byte prefix: Virtual JS modules use
\0prefix (Rollup convention for virtual modules) - Dev server URL rewriting:
virtual:becomes/@id/virtual:in dev mode - Root layout detection: Case-insensitive check for
root.*filename - Extension priority: Plugin checks
.tsx,.ts,.jsx,.jsin that order - Empty widgets directory: Plugin gracefully handles missing directory (returns empty array)
- Manifest path resolution: Uses
process.cwd()not Vite root for manifest resolution - HTML transformation timing: In dev, transformation happens after Vite's HTML pipeline
- BaseUrl trailing slash: Plugin normalizes baseUrl to ensure trailing slash for URL construction
- React Router HMR runtime: Only injected in dev mode when React Router plugin is detected