Tutorials
ChatGPT apps are now plugins: build their UI with MCP Apps
Most ChatGPT apps SDK widget tutorials still teach window.openai and text/html+skybridge. OpenAI now tells you to build on the MCP Apps standard first. Here is what changed and how to migrate.
7 min read
#The short answer
If you are building a ChatGPT apps SDK widget (now a ChatGPT plugin UI) in October 2026, build it as an MCP App: point the tool at its UI with _meta.ui.resourceUri, serve the HTML with the MIME type text/html;profile=mcp-app, and talk to the host through the MCP Apps bridge (App from @modelcontextprotocol/ext-apps). Keep window.openai only for the few things the shared standard does not cover, such as checkout or file uploads. That is OpenAI's own advice, and it gives you one widget that renders in ChatGPT, Claude and every other MCP Apps host.
The rest of this guide covers what changed, a field-by-field map from the Apps SDK to MCP Apps, a before and after code example, and the deprecations you should know about.
#What changed: ChatGPT apps became plugins
Three things moved in 2026:
- ChatGPT became fully MCP Apps compatible. The OpenAI developer changelog says it plainly on 22 February 2026: "ChatGPT is now fully compatible with the MCP Apps spec" (changelog). MCP Apps is the open extension for interactive UIs in MCP, stable since 26 January 2026 and co-authored by people from OpenAI, Anthropic and the MCP-UI project (spec). If the term is new to you, start with what MCP Apps are.
- Apps were renamed plugins. In July 2026 OpenAI renamed ChatGPT apps to plugins and replaced the App Directory with the Plugin Directory. OpenAI's help center dates the change to 9 July 2026; we could not load that page directly, so treat the exact day as reported, not confirmed.
- The developer docs moved. The UI documentation now lives under
developers.openai.com/plugins, for example Build your ChatGPT UI, which uses the word "plugin" throughout and has its own UI changelog.
That is why searches for "ChatGPT apps SDK widget" return a mix of old and new vocabulary. Many top results date from late 2025, when window.openai and text/html+skybridge were the only way to draw UI in ChatGPT. They still describe something that works, but they are no longer the recommended path.
#What OpenAI recommends now
OpenAI's page on MCP Apps in ChatGPT is direct: "Use the MCP Apps field or method whenever the shared specification covers the capability." New UI should use the shared fields and bridge methods, and once the MCP Apps flow works, you add window.openai only for capabilities the specification does not cover, with feature detection and a fallback when practical.
As of October 2026, the ChatGPT-only extensions OpenAI lists are:
| Extension | What it does |
|---|---|
window.openai.requestCheckout | Opens an embedded payment sheet |
window.openai.requestModal | Opens a modal controlled by the host |
window.openai.uploadFile, selectFiles, getFileDownloadUrl | File handling |
window.openai.widgetState, setWidgetState | Persists widget state |
Everything else (receiving tool input and results, calling server tools, opening links, sending messages, reporting size, reading the theme and locale) has a standard equivalent.
#Apps SDK to MCP Apps: the field map
The official migration guide from the ext-apps project maps every piece. On the server:
| Apps SDK | MCP Apps | Notes |
|---|---|---|
_meta["openai/outputTemplate"] | _meta.ui.resourceUri | The ui:// URI of the view |
_meta["openai/widgetAccessible"] | _meta.ui.visibility | true means the list includes "app" |
_meta["openai/visibility"] | _meta.ui.visibility | "private" means the list leaves out "model" |
text/html+skybridge | text/html;profile=mcp-app | Exported as RESOURCE_MIME_TYPE |
_meta["openai/widgetCSP"] | _meta.ui.csp | resource_domains, connect_domains, frame_domains become resourceDomains, connectDomains, frameDomains |
_meta["openai/widgetDomain"] | _meta.ui.domain | Dedicated sandbox origin |
_meta["openai/widgetPrefersBorder"] | _meta.ui.prefersBorder | Visual border preference |
In the view:
| Apps SDK | MCP Apps |
|---|---|
window.openai.toolInput | app.ontoolinput = (params) => params.arguments |
window.openai.toolOutput | app.ontoolresult = (params) => params.structuredContent |
window.openai.callTool(name, args) | app.callServerTool({ name, arguments: args }) |
window.openai.notifyIntrinsicHeight(height) | app.sendSizeChanged({ width, height }) |
window.openai.openExternal({ href }) | app.openLink({ url: href }) |
window.openai.theme, locale, displayMode | app.getHostContext()?.theme, locale, displayMode |
The default _meta.ui.visibility is ["model", "app"]: the model can call the tool and so can the view. Set ["app"] to hide a tool from the model while your UI can still call it.
#Before and after: the server
Here is a tool that shows a list of open orders, first with Apps SDK metadata. The handler is the same in both versions.
const handler = async ({ status }: { status: string }) => {
const orders = await listOrders(status)
return {
content: [{ type: 'text', text: `${orders.length} ${status} orders` }],
structuredContent: { orders },
}
}
// Before: Apps SDK metadata
server.registerResource('Orders view', 'ui://view/orders.html', { mimeType: 'text/html+skybridge' }, async () => ({
contents: [{
uri: 'ui://view/orders.html',
mimeType: 'text/html+skybridge',
text: ordersHtml,
_meta: { 'openai/widgetCSP': { resource_domains: ['https://cdn.example.com'] } },
}],
}))
server.registerTool('show_orders', {
title: 'Orders',
description: 'Shows the orders with a given status',
inputSchema: z.object({ status: z.string() }),
_meta: { 'openai/outputTemplate': 'ui://view/orders.html', 'openai/widgetAccessible': true },
}, handler)The same tool on MCP Apps, with the server helpers from @modelcontextprotocol/ext-apps/server:
import { RESOURCE_MIME_TYPE, registerAppResource, registerAppTool } from '@modelcontextprotocol/ext-apps/server'
registerAppResource(server, 'Orders view', 'ui://view/orders.html', { description: 'Orders UI' }, async () => ({
contents: [{
uri: 'ui://view/orders.html',
mimeType: RESOURCE_MIME_TYPE, // text/html;profile=mcp-app
text: ordersHtml,
_meta: { ui: { csp: { resourceDomains: ['https://cdn.example.com'] } } },
}],
}))
registerAppTool(server, 'show_orders', {
title: 'Orders',
description: 'Shows the orders with a given status',
inputSchema: z.object({ status: z.string() }),
_meta: { ui: { resourceUri: 'ui://view/orders.html' } }, // default visibility already includes "app"
}, handler)Keep the content text meaningful. A host that does not render MCP Apps shows the tool as plain text, so that line is what those users get.
#Before and after: the view
In the Apps SDK the view read globals that ChatGPT injected:
// Before: globals injected by ChatGPT
render(window.openai.toolOutput)
refreshButton.onclick = () => window.openai.callTool('show_orders', { status: 'open' })With MCP Apps the view creates an App, sets its handlers and then connects:
import { App } from '@modelcontextprotocol/ext-apps'
const app = new App({ name: 'Orders', version: '1.0.0' })
app.ontoolresult = (params) => render(params.structuredContent)
await app.connect()
refreshButton.onclick = async () => {
const result = await app.callServerTool({ name: 'show_orders', arguments: { status: 'open' } })
render(result.structuredContent)
}Two details catch people out. Set the handlers before connect(), or you can miss the first tool result. And the import needs a bundler (the official quickstart uses Vite with vite-plugin-singlefile) or a module loaded from a CDN, as Claude's quickstart does. The full walk-through is in build an MCP App in TypeScript.
If you still need a ChatGPT-only feature, detect it instead of assuming it:
if (window.openai?.requestModal) {
detailsButton.hidden = false
}#Deprecations to watch
openai/visibilityis deprecated since 21 July 2026. The changelog explains why: itsprivatevalue hid a tool from the model without saying whether the app could still call it. Use_meta.ui.visibilityinstead.openai/outputTemplatestill works, as a compatibility alias. OpenAI's docs say ChatGPT "also honors" it, and that_meta.ui.resourceUriis what gives you broader compatibility. Migrate when you next touch the tool; nothing breaks today.- The view MIME type in OpenAI's current docs is
text/html;profile=mcp-app. The older MCP Apps proposal usedtext/html+mcp; the stable spec requirestext/html;profile=mcp-app, and the SDK constantRESOURCE_MIME_TYPEalways has the current value. - Theme variables are standard too. Since 28 May 2026 ChatGPT provides the MCP Apps host CSS variables in
hostContext.styles.variables, so you can style for ChatGPT and Claude with one set of rules. See theming MCP Apps.
A naming note: "Skybridge" is also the name of an open source framework by Alpic for building MCP Apps and ChatGPT apps. Moving away from the text/html+skybridge MIME type has nothing to do with that project.
#One widget for ChatGPT and Claude
Once the view speaks MCP Apps, the same server and the same HTML serve both hosts. Claude's documentation says App.connect() without a transport "detects the host and picks the transport", and that registerAppTool and registerAppResource generate each host's metadata for you. What still differs between hosts:
- Content Security Policy. Both hosts block origins the resource does not declare. Claude restricts
frameDomainspending security review (design guidelines), and ChatGPT blocks nested frames by default and checks the declared policy in plugin review. Declare only what you load. The details are in MCP Apps CSP explained. ui.domain. Its value is host specific, so set it per host or leave it out.- Text-only hosts. Some MCP clients call your tool without drawing the UI. Claude Code in the terminal is one of them. Which hosts render what is covered in MCP Apps clients.
- ChatGPT extensions. Anything behind
window.openaiexists only in ChatGPT. Feature detect it and give Claude users a path that works without it.
If you have a large Apps SDK codebase, the ext-apps project ships an Agent Skill called migrate-oai-app that walks a coding agent through this same mapping (ext-apps on GitHub). For how MCP Apps relates to the Apps SDK and MCP-UI in general, read MCP Apps vs MCP-UI vs OpenAI Apps SDK.
#Where Widgetry fits
Widgetry builds widgets that are MCP Apps from the start: a ui:// resource served as text/html;profile=mcp-app, a tool that points at it with _meta.ui.resourceUri, and its CSP in _meta.ui.csp. There is no window.openai in them, so the same widget renders in ChatGPT and in Claude. You pick one of the templates (a board, KPIs, charts, a table, a 3D globe), change its data and design, and check it in a preview that speaks the same protocol as the chat hosts.
To use it from your own server, you link the published widget or download its HTML and manifest and register them with the official TypeScript SDK; add a UI to your MCP server shows both paths. Widgetry does not cover the ChatGPT-only extensions: if your plugin needs checkout or file uploads, you write that part yourself on top.
#Checklist
- Replace
openai/outputTemplatewith_meta.ui.resourceUri(the alias still works while you migrate). - Serve the view as
RESOURCE_MIME_TYPEinstead oftext/html+skybridge. - Move
openai/widgetCSPto_meta.ui.cspwith camelCase keys. - Replace
openai/visibilityandopenai/widgetAccessiblewith_meta.ui.visibility. - In the view, swap
window.openaireads and calls forApphandlers and methods, set beforeconnect(). - Keep
window.openaionly for checkout, modals, files and widget state, behind feature detection. - Test in ChatGPT and in Claude, and read the text fallback once as if you were a text-only host.