Guide for implementing ChatGPT Apps using OpenAI Apps SDK...
This skill provides comprehensive guidance for implementing ChatGPT Apps using the OpenAI Apps SDK - a framework that enables MCP servers to deliver rich, interactive UI experiences directly within ChatGPT conversations.
Use this skill when:
ChatGPT Apps are MCP-based applications with three key components:
window.openai runtimeUser prompt
β
ChatGPT model βββΊ MCP tool call βββΊ Your server βββΊ Tool response
β β
β ββ structuredContent (model reads)
β ββ content (narration)
β ββ _meta (widget-only data)
β β
ββββββ renders narration βββββ widget iframe ββββββββ
(HTML template + window.openai)
MCP Server:
text/html+skybridge_meta["openai/outputTemplate"] pointing to UI resourcesstructuredContent, content, and _metaWidget Runtime (window.openai):
toolInput, toolOutput, toolResponseMetadata, widgetStatecallTool, sendFollowUpMessageuploadFile, getFileDownloadUrlrequestDisplayMode, requestModal, notifyIntrinsicHeighttheme, displayMode, locale, userAgentSecurity Model:
openai/widgetCSP configuration for allowed domainsProject structure:
your-chatgpt-app/
ββ server/
β ββ src/index.ts # MCP server + tool handlers
ββ web/
β ββ src/component.tsx # React widget
β ββ dist/app.{js,css} # Bundled assets
ββ package.json
Install dependencies:
# MCP SDK
npm install @modelcontextprotocol/sdk zod
# React widget
cd web
npm install react@^18 react-dom@^18
npm install -D vite esbuild typescript
UI templates are MCP resources with text/html+skybridge MIME type:
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { readFileSync } from "node:fs";
const server = new McpServer({ name: "my-app", version: "1.0.0" });
// Read bundled widget assets
const JS = readFileSync("web/dist/app.js", "utf8");
const CSS = readFileSync("web/dist/app.css", "utf8");
// Register widget template
server.registerResource(
"app-widget",
"ui://widget/app.html",
{},
async () => ({
contents: [
{
uri: "ui://widget/app.html",
mimeType: "text/html+skybridge",
text: `
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<title>My App</title>
<style>${CSS}</style>
</head>
<body>
<div id="root"></div>
<script type="module">${JS}</script>
</body>
</html>
`.trim(),
_meta: {
"openai/widgetPrefersBorder": true,
"openai/widgetDomain": "https://chatgpt.com",
"openai/widgetCSP": {
connect_domains: ["https://api.yourservice.com"],
resource_domains: ["https://*.oaistatic.com"],
// Optional: redirect_domains, frame_domains
},
},
},
],
})
);
Template metadata:
openai/widgetPrefersBorder: Show visual border around widgetopenai/widgetDomain: Dedicated origin for widget (enables fullscreen button)openai/widgetCSP: Content Security Policy configurationconnect_domains: APIs the widget can fetch fromresource_domains: CDNs for static assets (images, fonts, scripts)redirect_domains: Hosts for openExternal without safe-link modalframe_domains: Allowed iframe origins (discouraged, requires review)Cache busting: When changing widget HTML/JS/CSS in breaking ways, use new URI or filename so ChatGPT loads fresh bundle.
Tools are the contract the ChatGPT model reasons about:
import { z } from "zod";
server.registerTool(
"search_restaurants",
{
title: "Search Restaurants",
description: "Find restaurants matching criteria",
inputSchema: {
type: "object",
properties: {
location: { type: "string" },
cuisine: { type: "string" },
},
required: ["location"],
},
_meta: {
"openai/outputTemplate": "ui://widget/app.html",
"openai/toolInvocation/invoking": "Searching restaurantsβ¦",
"openai/toolInvocation/invoked": "Found restaurants",
"openai/widgetAccessible": true, // Allow widget to call this tool
"openai/visibility": "public", // "public" or "private"
},
},
async ({ location, cuisine }) => {
const restaurants = await searchRestaurants(location, cuisine);
return {
// Concise JSON for the model to read
structuredContent: {
count: restaurants.length,
topResults: restaurants.slice(0, 3).map(r => ({
name: r.name,
rating: r.rating,
})),
},
// Optional narration for the model's response
content: [
{
type: "text",
text: `Found ${restaurants.length} restaurants in ${location}`
}
],
// Large/sensitive data exclusively for the widget
_meta: {
allRestaurants: restaurants,
searchTimestamp: new Date().toISOString(),
},
};
}
);
Tool metadata:
openai/outputTemplate: URI of the widget to renderopenai/toolInvocation/invoking: Status message while tool executesopenai/toolInvocation/invoked: Status message when completeopenai/widgetAccessible: Enable window.openai.callTool for this toolopenai/visibility: "public" (model + widget) or "private" (widget only)Data payloads:
structuredContent: Concise JSON the model reads (keep under 4k tokens)content: Optional Markdown/plaintext narration_meta: Widget-exclusive data (never sent to model)Design for idempotency: Model may retry calls, so handlers should be safe to execute multiple times.
React widget with window.openai:
// web/src/app.tsx
import { createRoot } from "react-dom/client";
import { useEffect, useState, useSyncExternalStore } from "react";
// Helper hook to read window.openai globals
function useOpenAiGlobal<K extends keyof typeof window.openai>(
key: K
): typeof window.openai[K] {
return useSyncExternalStore(
(onChange) => {
const handler = () => onChange();
window.addEventListener("openai:set_globals", handler);
return () => window.removeEventListener("openai:set_globals", handler);
},
() => window.openai[key]
);
}
// Helper hook for widget state persistence
function useWidgetState<T>(defaultValue: T) {
const savedState = useOpenAiGlobal("widgetState") as T;
const [state, setState] = useState<T>(savedState ?? defaultValue);
useEffect(() => {
if (savedState) setState(savedState);
}, [savedState]);
const setWidgetState = (newState: T | ((prev: T) => T)) => {
setState((prev) => {
const updated = typeof newState === "function"
? (newState as (prev: T) => T)(prev)
: newState;
window.openai.setWidgetState(updated);
return updated;
});
};
return [state, setWidgetState] as const;
}
function RestaurantList() {
const toolOutput = useOpenAiGlobal("toolOutput");
const theme = useOpenAiGlobal("theme");
const [selectedId, setSelectedId] = useWidgetState<string | null>(null);
const restaurants = toolOutput?._meta?.allRestaurants ?? [];
const handleRefresh = async () => {
await window.openai.callTool("search_restaurants", {
location: toolOutput?.location,
});
};
const handleFollowUp = async (restaurantName: string) => {
await window.openai.sendFollowUpMessage({
prompt: `Tell me more about ${restaurantName}`,
});
};
return (
<div className={`app ${theme}`}>
<h2>Restaurants</h2>
<button onClick={handleRefresh}>Refresh</button>
{restaurants.map((r) => (
<div
key={r.id}
className={selectedId === r.id ? "selected" : ""}
onClick={() => setSelectedId(r.id)}
>
<h3>{r.name}</h3>
<p>Rating: {r.rating}β</p>
<button onClick={() => handleFollowUp(r.name)}>
Learn More
</button>
</div>
))}
</div>
);
}
// Mount the app
const root = document.getElementById("root");
if (root) {
createRoot(root).render(<RestaurantList />);
}
Vanilla JavaScript version:
// web/src/app.ts
const toolOutput = window.openai.toolOutput;
const restaurants = toolOutput?._meta?.allRestaurants ?? [];
const root = document.getElementById("root");
root.innerHTML = `
<div class="restaurants">
<h2>Restaurants</h2>
${restaurants.map(r => `
<div class="card">
<h3>${r.name}</h3>
<p>Rating: ${r.rating}β</p>
</div>
`).join("")}
</div>
`;
// Subscribe to theme changes
window.addEventListener("openai:set_globals", () => {
document.body.className = window.openai.theme;
});
Use CSS variables that ChatGPT injects:
:root {
/* Fallback defaults */
--color-background-primary: light-dark(#ffffff, #171717);
--color-text-primary: light-dark(#171717, #fafafa);
--color-border: light-dark(#e5e5e5, #404040);
--font-sans: system-ui, -apple-system, sans-serif;
--border-radius-md: 8px;
--spacing-sm: 8px;
--spacing-md: 16px;
}
.app {
background: var(--color-background-primary);
color: var(--color-text-primary);
font-family: var(--font-sans);
padding: var(--spacing-md);
}
.card {
border: 1px solid var(--color-border);
border-radius: var(--border-radius-md);
padding: var(--spacing-md);
margin-bottom: var(--spacing-sm);
}
Optional: Use the Apps SDK UI kit at apps-sdk-ui for ready-made components.
Use Vite or esbuild to create single-file bundle:
// vite.config.ts
import { defineConfig } from "vite";
import { viteSingleFile } from "vite-plugin-singlefile";
export default defineConfig({
plugins: [viteSingleFile()],
build: {
outDir: "dist",
rollupOptions: {
input: "index.html"
}
}
});
Build command:
cd web
npm run build # Outputs dist/app.js and dist/app.css
npm run build in web/node dist/index.jshttp://localhost:<port>/mcpwindow.openai APIs in browser devtoolsRequirements:
ngrok http <port> or similar tunnelTest deployment:
# Tunnel localhost
ngrok http 3000
# Use ngrok URL when creating connector in ChatGPT
Protected Resource Metadata (/.well-known/oauth-protected-resource):
{
"resource": "https://your-mcp.example.com",
"authorization_servers": [
"https://auth.yourcompany.com"
],
"scopes_supported": ["read", "write"],
"resource_documentation": "https://yourcompany.com/docs"
}
Authorization Server Metadata (.well-known/oauth-authorization-server):
{
"issuer": "https://auth.yourcompany.com",
"authorization_endpoint": "https://auth.yourcompany.com/oauth2/authorize",
"token_endpoint": "https://auth.yourcompany.com/oauth2/token",
"registration_endpoint": "https://auth.yourcompany.com/oauth2/register",
"code_challenge_methods_supported": ["S256"],
"scopes_supported": ["read", "write"]
}
Tool Security Schemes:
server.registerTool(
"get_user_data",
{
title: "Get User Data",
inputSchema: { /* ... */ },
securitySchemes: [
{ type: "oauth2", scopes: ["read"] }
],
_meta: {
"openai/outputTemplate": "ui://widget/app.html"
}
},
async ({ input }, context) => {
// Verify token
if (!context.authorization) {
return {
content: [{ type: "text", text: "Authentication required" }],
_meta: {
"mcp/www_authenticate": [
'Bearer resource_metadata="https://your-mcp.example.com/.well-known/oauth-protected-resource", error="insufficient_scope", error_description="Login required"'
]
},
isError: true
};
}
// Verify token, scopes, audience, expiry
const user = await verifyToken(context.authorization);
const data = await fetchUserData(user.id);
return {
structuredContent: { summary: data.summary },
_meta: { fullData: data }
};
}
);
OAuth Flow:
Authorization: Bearer <token>Redirect URIs to allowlist:
https://chatgpt.com/connector_platform_oauth_redirecthttps://platform.openai.com/apps-manage/oauthEnable widgets to call tools directly:
// Tool definition
_meta: {
"openai/outputTemplate": "ui://widget/app.html",
"openai/widgetAccessible": true,
"openai/visibility": "public" // or "private" to hide from model
}
// Widget code
async function refreshData() {
const result = await window.openai.callTool("search_restaurants", {
location: "Paris"
});
// Widget automatically re-renders with new toolOutput
}
Upload files:
// Widget code
async function handleUpload(event: ChangeEvent<HTMLInputElement>) {
const file = event.target.files?.[0];
if (!file) return;
const { fileId } = await window.openai.uploadFile(file);
console.log("Uploaded:", fileId);
}
Download files:
// Widget code
const { downloadUrl } = await window.openai.getFileDownloadUrl({ fileId });
image.src = downloadUrl;
Tool file parameters:
server.registerTool(
"process_image",
{
title: "Process Image",
inputSchema: {
type: "object",
properties: {
image: {
type: "object",
properties: {
download_url: { type: "string" },
file_id: { type: "string" }
}
}
}
},
_meta: {
"openai/outputTemplate": "ui://widget/app.html",
"openai/fileParams": ["image"]
}
},
async ({ image }) => {
const response = await fetch(image.download_url);
const buffer = await response.arrayBuffer();
// Process image...
return { content: [], structuredContent: {} };
}
);
Request alternate layouts:
// Widget code
await window.openai.requestDisplayMode({ mode: "fullscreen" });
// Options: "inline", "pip", "fullscreen"
// Note: PiP may coerce to fullscreen on mobile
Spawn host-controlled overlays:
// Widget code
await window.openai.requestModal({
title: "Checkout",
component: "checkout-modal"
});
Use standard routing (React Router):
import { BrowserRouter, Routes, Route, useNavigate } from "react-router-dom";
function App() {
return (
<BrowserRouter>
<Routes>
<Route path="/" element={<ListView />} />
<Route path="/detail/:id" element={<DetailView />} />
</Routes>
</BrowserRouter>
);
}
function ListView() {
const navigate = useNavigate();
return (
<button onClick={() => navigate("/detail/123")}>
View Details
</button>
);
}
ChatGPT mirrors iframe history to UI navigation controls.
Read locale and format accordingly:
// Widget code
const locale = window.openai.locale ?? "en-US";
const formatter = new Intl.DateTimeFormat(locale);
const price = new Intl.NumberFormat(locale, {
style: "currency",
currency: "USD"
});
Or use i18n libraries:
import { IntlProvider } from "react-intl";
import en from "./locales/en-US.json";
import es from "./locales/es-ES.json";
const messages = { "en-US": en, "es-ES": es };
const locale = window.openai.locale ?? "en-US";
<IntlProvider locale={locale} messages={messages[locale]}>
<App />
</IntlProvider>
From widget:
window.openai.requestClose();
From server:
return {
content: [],
structuredContent: {},
_meta: {
"openai/closeWidget": true
}
};
structuredContent: Include only data needed for current promptalert, prompt, clipboard)fetch allowed only with CSP complianceframe_domains)401 and WWW-AuthenticateMCP Inspector: Test tools and widget rendering
http://localhost:<port>/mcpwindow.openai debuggingDogfooding: Test with trusted users before broad rollout
OAuth Testing: Use Inspector's Auth settings to walk through flow
Hosting options:
Requirements:
/.well-known/oauth-protected-resourceMonitoring:
Pre-submission checklist:
https://chatgpt.com/connector_platform_oauth_redirecthttps://platform.openai.com/apps-manage/oauthReview process:
frame_domains subject to stricter reviewSymptom: White screen or no widget appears
Solutions:
mimeType: "text/html+skybridge"openai/widgetCSP allows necessary domainswindow.openai UndefinedSymptom: Cannot read property 'toolOutput' of undefined
Solutions:
text/html+skybridge templatesSymptom: Network requests blocked, inline styles stripped
Solutions:
connect_domains (APIs)resource_domains (static assets)Symptom: Old widget version keeps loading after deploy
Solutions:
ui://widget/app-v2.htmlapp-20250101.jsSymptom: OAuth flow starts but never completes
Solutions:
code_challenge_methods_supported includes S256resource parameter echoed in tokensSymptom: Slow rendering, model performance degraded
Solutions:
structuredContent to essentials (< 4k tokens)_meta (widget-only)Official examples repository: openai-apps-sdk-examples
Ranked card list with favorites and CTAs
Embla-powered horizontal scroller for media-heavy layouts
Mapbox integration with fullscreen inspector
Stacked gallery for deep dives on single place
Scripted player with overlays
_metawidgetId = empty statestructuredContent or _metawindow.openai API| Category | Property/Method | Description |
|---|---|---|
| Data | toolInput |
Tool arguments from invocation |
| Data | toolOutput |
structuredContent from server |
| Data | toolResponseMetadata |
_meta payload (widget-only) |
| Data | widgetState |
Persisted UI state snapshot |
| Data | setWidgetState(state) |
Store new state (< 4k tokens) |
| Tools | callTool(name, args) |
Invoke MCP tool from widget |
| Messaging | sendFollowUpMessage({ prompt }) |
Post user-authored message |
| Files | uploadFile(file) |
Upload file, receive fileId |
| Files | getFileDownloadUrl({ fileId }) |
Get temp download URL |
| Layout | requestDisplayMode({ mode }) |
Request inline/PiP/fullscreen |
| Layout | requestModal({ ... }) |
Spawn host modal overlay |
| Layout | notifyIntrinsicHeight(height) |
Report dynamic height |
| Layout | requestClose() |
Close the widget |
| Navigation | openExternal({ href }) |
Open vetted external link |
| Context | theme |
"light" or "dark" |
| Context | displayMode |
"inline", "pip", or "fullscreen" |
| Context | locale |
RFC 4647 locale string |
| Context | userAgent |
Client user agent |
| Context | maxHeight |
Container max height |
| Context | safeArea |
Safe area insets |
| Context | view |
View context info |
| Key | Value | Description |
|---|---|---|
openai/outputTemplate |
"ui://widget/app.html" |
Widget URI to render |
openai/widgetAccessible |
true/false |
Enable callTool from widget |
openai/visibility |
"public"/"private" |
Model visibility |
openai/toolInvocation/invoking |
"Loadingβ¦" |
Status while executing |
openai/toolInvocation/invoked |
"Complete" |
Status when done |
openai/fileParams |
["image"] |
Fields treated as file params |
"openai/widgetCSP": {
connect_domains: ["https://api.example.com"], // Fetch destinations
resource_domains: ["https://cdn.example.com"], // Static assets
redirect_domains: ["https://checkout.example.com"], // openExternal
frame_domains: ["https://*.embed.example.com"] // Subframes (discouraged)
}
| Endpoint | Purpose |
|---|---|
/.well-known/oauth-protected-resource |
Protected resource metadata |
/.well-known/oauth-authorization-server |
OAuth 2.0 discovery |
/.well-known/openid-configuration |
OpenID Connect discovery |
Official Documentation:
Examples & Tools:
Community:
Authentication: