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:

ExtensionWhat it does
window.openai.requestCheckoutOpens an embedded payment sheet
window.openai.requestModalOpens a modal controlled by the host
window.openai.uploadFile, selectFiles, getFileDownloadUrlFile handling
window.openai.widgetState, setWidgetStatePersists 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 SDKMCP AppsNotes
_meta["openai/outputTemplate"]_meta.ui.resourceUriThe ui:// URI of the view
_meta["openai/widgetAccessible"]_meta.ui.visibilitytrue means the list includes "app"
_meta["openai/visibility"]_meta.ui.visibility"private" means the list leaves out "model"
text/html+skybridgetext/html;profile=mcp-appExported as RESOURCE_MIME_TYPE
_meta["openai/widgetCSP"]_meta.ui.cspresource_domains, connect_domains, frame_domains become resourceDomains, connectDomains, frameDomains
_meta["openai/widgetDomain"]_meta.ui.domainDedicated sandbox origin
_meta["openai/widgetPrefersBorder"]_meta.ui.prefersBorderVisual border preference

In the view:

Apps SDKMCP Apps
window.openai.toolInputapp.ontoolinput = (params) => params.arguments
window.openai.toolOutputapp.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, displayModeapp.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.

ts
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:

ts
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:

js
// 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:

js
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:

js
if (window.openai?.requestModal) {
  detailsButton.hidden = false
}

Deprecations to watch

  • openai/visibility is deprecated since 21 July 2026. The changelog explains why: its private value hid a tool from the model without saying whether the app could still call it. Use _meta.ui.visibility instead.
  • openai/outputTemplate still works, as a compatibility alias. OpenAI's docs say ChatGPT "also honors" it, and that _meta.ui.resourceUri is 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 used text/html+mcp; the stable spec requires text/html;profile=mcp-app, and the SDK constant RESOURCE_MIME_TYPE always 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 frameDomains pending 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.openai exists 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

  1. Replace openai/outputTemplate with _meta.ui.resourceUri (the alias still works while you migrate).
  2. Serve the view as RESOURCE_MIME_TYPE instead of text/html+skybridge.
  3. Move openai/widgetCSP to _meta.ui.csp with camelCase keys.
  4. Replace openai/visibility and openai/widgetAccessible with _meta.ui.visibility.
  5. In the view, swap window.openai reads and calls for App handlers and methods, set before connect().
  6. Keep window.openai only for checkout, modals, files and widget state, behind feature detection.
  7. Test in ChatGPT and in Claude, and read the text fallback once as if you were a text-only host.

Your first widget, in the chat in minutes

Pick a template, drop in your data and see it as ChatGPT or Claude will show it. Then link it from your MCP server, or let your agent build the next one.